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

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +448 -373
app.py CHANGED
@@ -10,33 +10,33 @@ import concurrent.futures
10
  from pathlib import Path
11
  from typing import List, Optional
12
 
13
- import numpy as np
14
  import spaces
 
15
  import torch
16
  from PIL import Image
17
- from diffusers import Flux2KleinPipeline, AutoencoderKLFlux2
18
 
19
  from gradio import Server
20
  from fastapi import Request, UploadFile, File, Form
21
  from fastapi.responses import HTMLResponse, JSONResponse, FileResponse
 
 
 
22
 
23
- # -------------------------------------------------------------------------
24
- # App Setup & Directories
25
- # -------------------------------------------------------------------------
26
  app = Server()
27
 
28
  BASE_DIR = Path(__file__).resolve().parent
 
29
  OUTPUT_DIR = BASE_DIR / "outputs"
30
  EXAMPLES_DIR = BASE_DIR / "examples"
31
 
 
32
  OUTPUT_DIR.mkdir(exist_ok=True)
33
- EXAMPLES_DIR.mkdir(exist_ok=True)
34
 
35
  MAX_SEED = np.iinfo(np.int32).max
36
  MAX_IMAGE_SIZE = 1024
37
 
38
- dtype = torch.bfloat16
39
  DEVICE = torch.device("cuda" if torch.cuda.is_available() else "cpu")
 
40
 
41
  if torch.cuda.is_available():
42
  print("current device:", torch.cuda.current_device())
@@ -45,99 +45,96 @@ if torch.cuda.is_available():
45
  else:
46
  DEVICE_LABEL = str(DEVICE).lower()
47
 
48
- # -------------------------------------------------------------------------
49
- # Model Loading
50
- # -------------------------------------------------------------------------
51
  print("Loading 4B Distilled model (Standard VAE)...")
52
  pipe_standard = Flux2KleinPipeline.from_pretrained(
53
  "black-forest-labs/FLUX.2-klein-4B",
54
  torch_dtype=dtype,
55
- ).to(DEVICE)
56
- # pipe_standard.enable_model_cpu_offload() # Uncomment if memory is tight
 
57
 
58
  print("Loading Small Decoder VAE...")
59
  vae_small = AutoencoderKLFlux2.from_pretrained(
60
  "black-forest-labs/FLUX.2-small-decoder",
61
  torch_dtype=dtype,
62
- ).to(DEVICE)
 
63
 
64
  print("Loading 4B Distilled model (Small Decoder VAE)...")
65
  pipe_small_decoder = Flux2KleinPipeline.from_pretrained(
66
  "black-forest-labs/FLUX.2-klein-4B",
67
  vae=vae_small,
68
  torch_dtype=dtype,
69
- ).to(DEVICE)
70
- # pipe_small_decoder.enable_model_cpu_offload() # Uncomment if memory is tight
 
71
 
72
  pipe_lock_standard = threading.Lock()
73
- pipe_lock_small = threading.Lock()
74
 
75
 
76
- # -------------------------------------------------------------------------
77
- # Image Processing Utilities
78
- # -------------------------------------------------------------------------
79
- def save_image(img: Image.Image, prefix: str = "output") -> str:
80
- filename = f"{prefix}_{uuid.uuid4().hex}.png"
81
- path = OUTPUT_DIR / filename
82
- img.save(path, format="PNG")
83
- return filename
84
-
85
  def calc_dimensions(pil_img: Image.Image):
86
  iw, ih = pil_img.size
87
  aspect = iw / ih
88
 
89
- if aspect >= 1: # landscape / square
90
- new_width = 1024
91
  new_height = int(round(1024 / aspect))
92
- else: # portrait
93
  new_height = 1024
94
- new_width = int(round(1024 * aspect))
95
 
96
- # snap to 8-pixel grid with round(), clamp to [256, 1024]
97
- new_width = max(256, min(1024, round(new_width / 8) * 8))
98
  new_height = max(256, min(1024, round(new_height / 8) * 8))
99
  return new_width, new_height
100
 
101
- def parse_and_resize_images(input_paths: List[str], width: int, height: int):
102
- if not input_paths:
 
103
  return None
104
 
105
  raw_list = []
106
- for src in input_paths:
107
  try:
108
- if os.path.exists(src):
109
- raw_list.append(Image.open(src).convert("RGB"))
 
 
 
 
110
  except Exception as e:
111
  print(f"Skipping invalid image: {e}")
112
 
113
  if not raw_list:
114
  return None
115
 
116
- resized = [
117
- img.resize((width, height), Image.LANCZOS)
118
- for img in raw_list
119
- ]
120
- return resized
121
 
122
  def run_pipeline(pipe, lock, kwargs, seed):
123
  with lock:
124
- gen = torch.Generator(device=DEVICE).manual_seed(seed)
125
  result = pipe(**kwargs, generator=gen).images[0]
126
  return result
127
 
128
- # -------------------------------------------------------------------------
129
- # Inference Function
130
- # -------------------------------------------------------------------------
 
 
 
 
 
131
  @spaces.GPU(duration=120)
132
  def infer(
133
- prompt: str,
134
- input_paths: List[str],
135
- seed: int,
136
- randomize_seed: bool,
137
- width: int,
138
- height: int,
139
- num_inference_steps: int,
140
- guidance_scale: float,
141
  ):
142
  gc.collect()
143
  if torch.cuda.is_available():
@@ -150,13 +147,20 @@ def infer(
150
  seed = random.randint(0, MAX_SEED)
151
 
152
  image_list = None
153
- if input_paths:
154
- first_pil = Image.open(input_paths[0]).convert("RGB")
155
- width, height = calc_dimensions(first_pil)
156
- image_list = parse_and_resize_images(input_paths, width, height)
 
 
 
 
157
 
158
- width = max(256, min(MAX_IMAGE_SIZE, round(int(width) / 8) * 8))
159
- height = max(256, min(MAX_IMAGE_SIZE, round(int(height) / 8) * 8))
 
 
 
160
 
161
  shared_kwargs = dict(
162
  prompt=prompt,
@@ -168,21 +172,13 @@ def infer(
168
  if image_list is not None:
169
  shared_kwargs["image"] = image_list
170
 
171
- # Run both models concurrently
172
  with concurrent.futures.ThreadPoolExecutor(max_workers=2) as executor:
173
- future_std = executor.submit(
174
- run_pipeline, pipe_standard, pipe_lock_standard, shared_kwargs, seed
175
- )
176
- future_small = executor.submit(
177
- run_pipeline, pipe_small_decoder, pipe_lock_small, shared_kwargs, seed
178
- )
179
- concurrent.futures.wait(
180
- [future_std, future_small],
181
- return_when=concurrent.futures.ALL_COMPLETED,
182
- )
183
 
184
  out_standard = future_std.result()
185
- out_small = future_small.result()
186
 
187
  gc.collect()
188
  if torch.cuda.is_available():
@@ -191,9 +187,36 @@ def infer(
191
  return out_standard, out_small, seed
192
 
193
 
194
- # -------------------------------------------------------------------------
195
- # API Endpoints
196
- # -------------------------------------------------------------------------
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
197
  @app.get("/download/{filename}")
198
  async def download_file(filename: str):
199
  path = OUTPUT_DIR / filename
@@ -201,8 +224,9 @@ async def download_file(filename: str):
201
  return JSONResponse({"error": "File not found"}, status_code=404)
202
  return FileResponse(path, filename=filename, media_type="image/png")
203
 
 
204
  @app.post("/api/compare")
205
- async def compare_images(
206
  prompt: str = Form(...),
207
  seed: str = Form("0"),
208
  randomize_seed: str = Form("true"),
@@ -217,8 +241,6 @@ async def compare_images(
217
  image_paths = []
218
  if images:
219
  for upload in images:
220
- if not upload.filename:
221
- continue
222
  suffix = Path(upload.filename).suffix or ".png"
223
  temp_name = f"upload_{uuid.uuid4().hex}{suffix}"
224
  temp_path = OUTPUT_DIR / temp_name
@@ -229,26 +251,28 @@ async def compare_images(
229
  image_paths.append(str(temp_path))
230
 
231
  out_std, out_small, used_seed = infer(
 
232
  prompt=prompt,
233
- input_paths=image_paths,
234
  seed=int(seed),
235
  randomize_seed=randomize_seed.lower() == "true",
236
  width=int(width),
237
  height=int(height),
238
  num_inference_steps=int(steps),
239
- guidance_scale=float(guidance)
240
  )
241
 
242
  std_filename = save_image(out_std, prefix="std")
243
- sml_filename = save_image(out_small, prefix="sml")
244
-
245
- return JSONResponse({
246
- "success": True,
247
- "seed": used_seed,
248
- "std_url": f"/download/{std_filename}",
249
- "sml_url": f"/download/{sml_filename}",
250
- "device": DEVICE_LABEL,
251
- })
 
 
252
 
253
  except Exception as e:
254
  return JSONResponse({"success": False, "error": str(e)}, status_code=500)
@@ -261,448 +285,499 @@ async def compare_images(
261
  pass
262
 
263
 
264
- # -------------------------------------------------------------------------
265
- # Frontend Template
266
- # -------------------------------------------------------------------------
267
  @app.get("/", response_class=HTMLResponse)
268
  async def homepage(request: Request):
 
 
 
269
  return f"""
270
  <!DOCTYPE html>
271
  <html lang="en">
272
  <head>
273
  <meta charset="UTF-8" />
274
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
275
- <title>Flux.2-4B-Decoder-Comparator</title>
276
  <style>
277
  @import url('https://fonts.googleapis.com/css2?family=Ubuntu:wght@300;400;500;700&display=swap');
278
 
279
  :root {{
280
- /* Ubuntu Brand Colors */
281
- --ubuntu-orange: #E95420;
282
- --ubuntu-orange-hover: #C7481B;
283
- --ubuntu-aubergine: #77216F;
284
- --ubuntu-aubergine-dark: #2C001E;
285
- --ubuntu-warm-grey: #AEA79F;
286
-
287
  --bg: #111111;
288
- --panel: #1e1e1e;
289
  --panel-2: #252525;
 
290
  --border: #333333;
291
- --muted: #9CA3AF;
292
- --text: #F5F7FB;
293
- --text-dim: #D1D5DB;
294
- --input-bg: #181818;
295
- --same-height: 760px;
 
 
 
296
  }}
297
 
298
- * {{ box-sizing: border-box; border-radius: 4px; }}
299
-
300
  html, body {{
301
  margin: 0; padding: 0;
302
- background: var(--bg);
303
- color: var(--text);
304
- font-family: 'Ubuntu', sans-serif;
305
- min-height: 100%;
306
- overflow-x: hidden;
307
  }}
308
 
309
  .topbar {{
310
  height: 60px;
 
311
  border-bottom: 2px solid var(--ubuntu-orange);
312
- background: var(--ubuntu-aubergine-dark);
313
- display: flex;
314
- align-items: center;
315
- justify-content: center;
316
- padding: 0 24px;
317
- color: white;
318
- font-size: 16px;
319
- font-weight: 500;
320
- letter-spacing: 0.5px;
321
  }}
322
 
323
- .container {{
324
- max-width: 1440px;
325
- margin: 0 auto;
326
- padding: 28px;
327
- }}
328
 
329
  .hero {{ margin-bottom: 24px; padding-bottom: 20px; border-bottom: 1px solid var(--border); }}
330
- .title {{ font-size: 38px; line-height: 1.2; font-weight: 700; margin: 0 0 8px 0; color: white; }}
331
- .subtitle {{ color: var(--ubuntu-warm-grey); font-size: 16px; margin:0; }}
332
- .subtitle a {{ color: var(--ubuntu-orange); text-decoration: none; }}
333
 
334
  .layout {{
335
- display: grid;
336
- grid-template-columns: 450px 1fr;
337
- gap: 24px;
338
- align-items: stretch;
339
  }}
340
 
341
  .panel {{
342
- background: var(--panel);
343
- border: 1px solid var(--border);
344
- min-height: var(--same-height);
345
- display: flex;
346
- flex-direction: column;
347
  }}
348
 
349
  .panel-header {{
350
- height: 56px;
351
- border-bottom: 1px solid var(--border);
352
  display: flex; align-items: center; justify-content: space-between;
353
- padding: 0 18px; background: var(--panel-2);
354
  }}
355
- .panel-title {{ font-size: 18px; font-weight: 700; margin: 0; color: white; }}
356
-
357
- .panel-body {{ padding: 18px; flex: 1; overflow: auto; display: flex; flex-direction: column; gap: 16px; }}
358
 
359
- .label {{ font-size: 14px; font-weight: 500; color: var(--text-dim); margin-bottom: 6px; display: block; }}
360
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
361
  .input, .textarea {{
362
- width: 100%;
363
- background: var(--input-bg);
364
- border: 1px solid var(--border);
365
- color: var(--text);
366
- outline: none; padding: 12px; font-size: 15px; font-family: 'Ubuntu', sans-serif;
367
  }}
368
- .input:focus, .textarea:focus {{ border-color: var(--ubuntu-orange); }}
369
  .textarea {{ min-height: 120px; resize: vertical; }}
370
 
371
  .upload-wrap {{
372
- background: var(--input-bg); border: 1px dashed var(--border);
373
- min-height: 160px; display: flex; flex-direction: column;
374
- align-items: center; justify-content: center; padding: 14px; cursor: pointer; text-align: center;
375
  }}
376
- .upload-wrap:hover {{ border-color: var(--ubuntu-orange); }}
377
  .upload-wrap input[type="file"] {{ display: none; }}
378
 
379
- .preview-grid {{ display: grid; grid-template-columns: repeat(auto-fill, minmax(80px, 1fr)); gap: 8px; width:100%; display:none; }}
380
- .thumb {{ position: relative; aspect-ratio: 1; overflow: hidden; border: 1px solid var(--border); }}
381
- .thumb img {{ width: 100%; height: 100%; object-fit: cover; }}
 
 
 
 
 
382
  .thumb-remove {{
383
- position: absolute; top: 4px; right: 4px; width: 20px; height: 20px;
384
- background: rgba(0,0,0,0.8); color: white; border: none; cursor: pointer;
385
- display: flex; align-items: center; justify-content: center; font-size: 12px; border-radius: 50%;
386
  }}
387
 
 
388
  .advanced-toggle {{
389
- width: 100%; height: 40px; border: 1px solid var(--border); background: var(--panel-2);
390
- color: var(--text); display: flex; align-items: center; justify-content: space-between;
391
- padding: 0 14px; cursor: pointer; font-weight: 500;
392
  }}
393
- .advanced-body {{ display: none; padding: 14px; background: var(--input-bg); border: 1px solid var(--border); border-top: none; }}
394
  .advanced-body.open {{ display: block; }}
395
-
396
- .grid-2 {{ display: grid; grid-template-columns: 1fr 1fr; gap: 12px; margin-bottom: 12px; }}
397
 
398
- .actions {{ display: grid; grid-template-columns: 1fr; gap: 12px; margin-top: auto; }}
399
  .btn {{
400
- height: 48px; border: none; font-size: 16px; font-weight: 700; cursor: pointer;
401
- font-family: 'Ubuntu', sans-serif; transition: background 0.2s; color: white;
402
  }}
403
- .btn-primary {{ background: var(--ubuntu-orange); }}
 
404
  .btn-primary:hover {{ background: var(--ubuntu-orange-hover); }}
405
 
406
- /* --- Slider CSS --- */
407
- .result-shell {{ position: relative; flex: 1; min-height: 400px; border: 1px solid var(--border); background: #000; overflow: hidden; display: flex; align-items: center; justify-content: center; }}
408
-
409
- .comparison-container {{
410
- position: relative;
411
- width: 100%;
412
- height: 100%;
413
- display: none; /* Hidden until loaded */
414
  }}
415
 
416
- .comparison-container img {{
417
- position: absolute;
418
- top: 0; left: 0;
419
- width: 100%; height: 100%;
420
- object-fit: contain;
421
- pointer-events: none;
422
  }}
423
-
424
- #imgStd {{ z-index: 1; }}
425
- #imgSml {{ z-index: 2; clip-path: polygon(0 0, 50% 0, 50% 100%, 0 100%); }}
426
-
427
- .slider-input {{
428
- position: absolute;
429
- top: 0; left: 0;
430
- width: 100%; height: 100%;
431
- outline: none;
432
- background: transparent;
433
- -webkit-appearance: none;
434
- margin: 0;
435
- z-index: 4;
436
- cursor: ew-resize;
437
  }}
438
 
439
- .slider-input::-webkit-slider-thumb {{
440
- -webkit-appearance: none;
441
- width: 40px; height: 1000px;
442
- background: transparent;
443
- cursor: ew-resize;
444
- }}
445
-
446
- .slider-line {{
447
- position: absolute;
448
- top: 0; bottom: 0; left: 50%;
449
- width: 4px;
450
- background: var(--ubuntu-orange);
451
- z-index: 3;
452
- transform: translateX(-50%);
453
- pointer-events: none;
454
  }}
455
 
456
- .slider-pill {{
457
- position: absolute;
458
- top: 50%; left: 50%;
459
- transform: translate(-50%, -50%);
460
- width: 32px; height: 32px;
461
- background: var(--ubuntu-orange);
462
- border-radius: 50%;
463
- display: flex; align-items: center; justify-content: center;
464
- z-index: 3; color: white;
465
  }}
466
-
467
- .decoder-label {{
468
- position: absolute; top: 16px; padding: 6px 12px; background: rgba(0,0,0,0.6);
469
- color: white; font-size: 13px; font-weight: 700; border-radius: 4px; z-index: 3;
470
- backdrop-filter: blur(4px);
471
  }}
472
- .decoder-label.left {{ left: 16px; }}
473
- .decoder-label.right {{ right: 16px; }}
474
 
475
- .empty-state {{
476
- position: absolute; inset:0; display: flex; flex-direction: column; align-items: center;
477
- justify-content: center; color: var(--text-dim); text-align: center; padding: 20px; z-index: 0;
 
478
  }}
 
 
479
 
 
 
 
 
480
  .loader {{
481
  position: absolute; inset: 0; display: none; align-items: center; justify-content: center;
482
- flex-direction: column; gap: 14px; background: rgba(0,0,0,0.7); backdrop-filter: blur(5px); z-index: 10;
 
 
 
 
483
  }}
484
- .spinner {{ width: 40px; height: 40px; border: 4px solid rgba(255,255,255,0.2); border-top-color: var(--ubuntu-orange); border-radius: 50%; animation: spin 1s linear infinite; }}
 
 
 
 
 
 
 
 
 
 
 
 
 
485
  @keyframes spin {{ to {{ transform: rotate(360deg); }} }}
486
 
487
- @media (max-width: 980px) {{
488
  .layout {{ grid-template-columns: 1fr; }}
489
- .panel {{ min-height: auto; }}
490
- .result-shell {{ min-height: 500px; }}
491
  }}
492
  </style>
493
  </head>
494
  <body>
495
- <div class="topbar">FLUX.2 Experimental Space</div>
 
496
 
497
  <div class="container">
498
- <div class="hero">
499
- <h1 class="title">Flux.2-4B-Decoder-Comparator</h1>
500
- <p class="subtitle">Compare <b>FLUX.2-klein-4B</b> side-by-side with <a href="#" target="_blank">small decoder</a>.</p>
501
- </div>
502
 
503
- <div class="layout">
 
504
  <div class="panel">
505
- <div class="panel-header"><h2 class="panel-title">Inputs</h2></div>
 
 
 
506
  <div class="panel-body">
507
 
508
  <div>
509
  <span class="label">Input Images</span>
510
  <div class="upload-wrap" id="uploadZone">
511
  <input id="fileInput" type="file" accept="image/*" multiple />
512
- <div id="uploadPlaceholder">
513
- <svg viewBox="0 0 24 24" width="32" height="32" fill="none" stroke="currentColor" stroke-width="2" style="margin-bottom:8px; color:var(--ubuntu-warm-grey);">
514
- <path d="M12 4v10"></path><path d="M8.5 7.5 12 4l3.5 3.5"></path><path d="M4 16.5h16"></path><path d="M6 20h12"></path>
515
  </svg>
516
- <div style="font-weight:500;">Click or Drag images here</div>
517
  </div>
518
- <div class="preview-grid" id="previewGrid"></div>
519
  </div>
 
520
  </div>
521
 
522
  <div>
523
- <label class="label" for="prompt">Prompt</label>
524
- <textarea id="prompt" class="textarea" placeholder="e.g., A black cat holding a sign that says hello world..."></textarea>
525
  </div>
526
 
527
- <div>
528
- <button class="advanced-toggle" id="advancedToggle" type="button">
529
- <span>Advanced Settings</span><span id="advIcon">+</span>
530
  </button>
531
  <div class="advanced-body" id="advancedBody">
532
- <div class="grid-2">
533
- <div><label class="label">Seed</label><input class="input" type="number" id="seed" value="0"></div>
534
- <div><label class="label">Steps</label><input class="input" type="number" id="steps" value="4" min="1" max="20"></div>
535
- <div><label class="label">Width</label><input class="input" type="number" id="width" value="1024" step="8"></div>
536
- <div><label class="label">Height</label><input class="input" type="number" id="height" value="1024" step="8"></div>
537
- <div style="grid-column: span 2;"><label class="label">Guidance</label><input class="input" type="number" id="guidance" value="1.0" step="0.1"></div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
538
  </div>
539
- <label style="display:flex; align-items:center; gap:8px; font-size:14px; color:var(--text-dim); cursor:pointer;">
540
- <input type="checkbox" id="randSeed" checked> Randomize Seed
541
- </label>
542
  </div>
543
  </div>
544
 
545
  <div class="actions">
546
- <button class="btn btn-primary" id="runBtn" type="button">Run Comparison</button>
 
547
  </div>
548
  </div>
549
  </div>
550
 
551
  <div class="panel">
552
  <div class="panel-header">
553
- <h2 class="panel-title">Comparison Result</h2>
554
- <span style="font-size:13px; color:var(--muted);" id="metaText">Idle</span>
555
  </div>
556
- <div class="panel-body" style="padding:0;">
557
- <div class="result-shell">
558
-
559
- <div class="empty-state" id="emptyState">
560
- <svg viewBox="0 0 24 24" width="48" height="48" fill="none" stroke="currentColor" stroke-width="1.5" style="margin-bottom:12px;">
561
- <rect x="3" y="3" width="18" height="18" rx="2"></rect><circle cx="8.5" cy="8.5" r="1.5"></circle><path d="M21 15l-5-5L5 21"></path>
 
 
562
  </svg>
563
- <div>Your outputs will appear here</div>
564
  </div>
565
 
566
- <div class="comparison-container" id="compContainer">
567
- <img id="imgStd" alt="Standard Decoder">
568
- <img id="imgSml" alt="Small Decoder">
569
 
570
- <div class="decoder-label left">Standard VAE</div>
571
- <div class="decoder-label right">Small Decoder</div>
572
-
573
- <div class="slider-line" id="sliderLine">
574
- <div class="slider-pill">
575
- <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path d="M15 18l-6-6 6-6"/></svg>
576
- <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path d="M9 18l6-6-6-6"/></svg>
577
- </div>
578
  </div>
579
- <input type="range" min="0" max="100" value="50" class="slider-input" id="compareSlider">
 
580
  </div>
581
 
582
- <div class="loader" id="loader">
583
  <div class="spinner"></div>
584
- <div style="font-weight:500;">Running parallel pipelines...</div>
585
  </div>
 
586
 
 
 
 
 
 
 
 
 
 
587
  </div>
 
588
  </div>
589
  </div>
590
- </div>
 
 
 
 
 
591
  </div>
592
 
593
  <script>
594
- const state = {{ files: [], advanced: false }};
595
-
596
- const uploadZone = document.getElementById('uploadZone');
597
- const fileInput = document.getElementById('fileInput');
598
- const previewGrid = document.getElementById('previewGrid');
599
- const uploadPlaceholder = document.getElementById('uploadPlaceholder');
 
 
600
 
601
- const advToggle = document.getElementById('advancedToggle');
602
- const advBody = document.getElementById('advancedBody');
603
- const advIcon = document.getElementById('advIcon');
604
 
605
- const runBtn = document.getElementById('runBtn');
606
- const loader = document.getElementById('loader');
607
 
608
- const compContainer = document.getElementById('compContainer');
609
- const emptyState = document.getElementById('emptyState');
610
- const imgStd = document.getElementById('imgStd');
611
- const imgSml = document.getElementById('imgSml');
612
- const compareSlider = document.getElementById('compareSlider');
613
- const sliderLine = document.getElementById('sliderLine');
614
- const metaText = document.getElementById('metaText');
615
-
616
- // -- File Upload Logic --
617
- function renderFiles() {{
618
- previewGrid.innerHTML = '';
619
- if(state.files.length === 0) {{
620
- uploadPlaceholder.style.display = 'block';
621
- previewGrid.style.display = 'none';
 
 
 
 
 
622
  return;
623
  }}
624
- uploadPlaceholder.style.display = 'none';
625
- previewGrid.style.display = 'grid';
626
-
627
  state.files.forEach((file, idx) => {{
628
- const div = document.createElement('div');
629
- div.className = 'thumb';
630
- const img = document.createElement('img');
631
- img.src = URL.createObjectURL(file);
632
- const btn = document.createElement('button');
633
- btn.className = 'thumb-remove';
634
- btn.innerHTML = '&times;';
635
- btn.onclick = (e) => {{ e.stopPropagation(); state.files.splice(idx,1); renderFiles(); }};
636
- div.appendChild(img); div.appendChild(btn);
637
- previewGrid.appendChild(div);
638
  }});
639
  }}
640
 
641
- uploadZone.onclick = (e) => {{ if(e.target === uploadZone || e.target.closest('#uploadPlaceholder')) fileInput.click(); }};
642
- fileInput.onchange = (e) => {{ state.files.push(...Array.from(e.target.files)); renderFiles(); fileInput.value = ''; }};
643
- uploadZone.ondragover = (e) => e.preventDefault();
644
- uploadZone.ondrop = (e) => {{ e.preventDefault(); state.files.push(...Array.from(e.dataTransfer.files)); renderFiles(); }};
 
 
 
 
 
 
 
 
 
 
645
 
646
- // -- Advanced Toggle --
647
- advToggle.onclick = () => {{
648
- state.advanced = !state.advanced;
649
- advBody.classList.toggle('open', state.advanced);
650
- advIcon.textContent = state.advanced ? '−' : '+';
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
651
  }};
652
 
653
- // -- Slider Logic --
654
- compareSlider.addEventListener('input', (e) => {{
655
- const val = e.target.value;
656
- imgSml.style.clipPath = `polygon(0 0, ${{val}}% 0, ${{val}}% 100%, 0 100%)`;
657
- sliderLine.style.left = `${{val}}%`;
 
 
 
 
 
 
 
 
 
658
  }});
659
 
660
- // -- API Submission --
661
  runBtn.onclick = async () => {{
662
- const prompt = document.getElementById('prompt').value.trim();
663
- if(!prompt) return alert('Please enter a prompt');
664
-
665
- const formData = new FormData();
666
- formData.append('prompt', prompt);
667
- formData.append('seed', document.getElementById('seed').value);
668
- formData.append('randomize_seed', document.getElementById('randSeed').checked);
669
- formData.append('width', document.getElementById('width').value);
670
- formData.append('height', document.getElementById('height').value);
671
- formData.append('steps', document.getElementById('steps').value);
672
- formData.append('guidance', document.getElementById('guidance').value);
673
 
674
- state.files.forEach(f => formData.append('images', f));
675
-
676
- loader.style.display = 'flex';
 
 
 
 
 
 
 
 
 
677
  runBtn.disabled = true;
678
 
679
  try {{
680
- const res = await fetch('/api/compare', {{ method: 'POST', body: formData }});
681
  const data = await res.json();
682
 
683
- if(!data.success) throw new Error(data.error);
 
 
 
 
684
 
685
- // Load images into slider
686
- imgStd.src = data.std_url + '?t=' + Date.now();
687
- imgSml.src = data.sml_url + '?t=' + Date.now();
688
 
689
- emptyState.style.display = 'none';
690
- compContainer.style.display = 'block';
 
 
691
 
692
- // Reset slider to center
693
- compareSlider.value = 50;
694
- compareSlider.dispatchEvent(new Event('input'));
695
-
696
- metaText.textContent = `Seed: ${{data.seed}} | Device: ${{data.device}}`;
697
-
698
- }} catch(err) {{
699
- alert('Error: ' + err.message);
700
  }} finally {{
701
- loader.style.display = 'none';
702
  runBtn.disabled = false;
 
703
  }}
704
  }};
705
-
706
  </script>
707
  </body>
708
  </html>
 
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
+ 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"
29
  OUTPUT_DIR = BASE_DIR / "outputs"
30
  EXAMPLES_DIR = BASE_DIR / "examples"
31
 
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())
 
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
 
56
  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)...")
64
  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
 
72
  pipe_lock_standard = threading.Lock()
73
+ pipe_lock_small = threading.Lock()
74
 
75
 
 
 
 
 
 
 
 
 
 
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
 
114
  def run_pipeline(pipe, lock, kwargs, seed):
115
  with lock:
116
+ gen = torch.Generator(device="cpu").manual_seed(seed)
117
  result = pipe(**kwargs, generator=gen).images[0]
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
124
+ img.save(path, format="PNG")
125
+ return filename
126
+
127
+
128
  @spaces.GPU(duration=120)
129
  def infer(
130
+ prompt,
131
+ input_images=None,
132
+ seed=42,
133
+ randomize_seed=False,
134
+ width=1024,
135
+ height=1024,
136
+ num_inference_steps=4,
137
+ guidance_scale=1.0,
138
  ):
139
  gc.collect()
140
  if torch.cuda.is_available():
 
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,
 
172
  if image_list is not None:
173
  shared_kwargs["image"] = image_list
174
 
 
175
  with concurrent.futures.ThreadPoolExecutor(max_workers=2) as executor:
176
+ future_std = executor.submit(run_pipeline, pipe_standard, pipe_lock_standard, shared_kwargs, seed)
177
+ future_small = executor.submit(run_pipeline, pipe_small_decoder, pipe_lock_small, shared_kwargs, seed)
178
+ concurrent.futures.wait([future_std, future_small], return_when=concurrent.futures.ALL_COMPLETED)
 
 
 
 
 
 
 
179
 
180
  out_standard = future_std.result()
181
+ out_small = future_small.result()
182
 
183
  gc.collect()
184
  if torch.cuda.is_available():
 
187
  return out_standard, out_small, seed
188
 
189
 
190
+ def get_example_items():
191
+ example_prompts = {
192
+ "1.jpg": "Change the weather to stormy.",
193
+ "2.jpg": "Transform the scene into a snowy winter day while preserving the original subject identity, framing, and composition.",
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
215
+ if not path.exists():
216
+ return JSONResponse({"error": "Example not found"}, status_code=404)
217
+ return FileResponse(path)
218
+
219
+
220
  @app.get("/download/{filename}")
221
  async def download_file(filename: str):
222
  path = OUTPUT_DIR / filename
 
224
  return JSONResponse({"error": "File not found"}, status_code=404)
225
  return FileResponse(path, filename=filename, media_type="image/png")
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"),
 
241
  image_paths = []
242
  if images:
243
  for upload in images:
 
 
244
  suffix = Path(upload.filename).suffix or ".png"
245
  temp_name = f"upload_{uuid.uuid4().hex}{suffix}"
246
  temp_path = OUTPUT_DIR / temp_name
 
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)
 
285
  pass
286
 
287
 
 
 
 
288
  @app.get("/", response_class=HTMLResponse)
289
  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>