prithivMLmods commited on
Commit
a31e72d
·
verified ·
1 Parent(s): 04a4e1c

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +454 -1132
app.py CHANGED
@@ -1,6 +1,6 @@
1
  import os
2
- import gc
3
  import io
 
4
  import uuid
5
  import json
6
  import base64
@@ -8,179 +8,167 @@ import random
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
 
15
  import torch
16
  from PIL import Image
 
17
 
18
  from gradio import Server
19
  from fastapi import Request, UploadFile, File, Form
20
  from fastapi.responses import HTMLResponse, JSONResponse, FileResponse
21
 
22
- from diffusers import Flux2KleinPipeline, AutoencoderKLFlux2
 
 
 
23
 
24
- # ── paths & constants ────────────────────────────────────────────────────────
25
- BASE_DIR = Path(__file__).resolve().parent
26
- STATIC_DIR = BASE_DIR / "static"
27
- OUTPUT_DIR = BASE_DIR / "outputs"
28
  EXAMPLES_DIR = BASE_DIR / "examples"
29
 
30
- STATIC_DIR.mkdir(exist_ok=True)
31
  OUTPUT_DIR.mkdir(exist_ok=True)
 
32
 
33
- MAX_SEED = np.iinfo(np.int32).max
34
  MAX_IMAGE_SIZE = 1024
35
 
36
- # ── device ───────────────────────────────────────────────────────────────────
37
- DEVICE = torch.device("cuda" if torch.cuda.is_available() else "cpu")
38
  dtype = torch.bfloat16
 
39
 
40
  if torch.cuda.is_available():
41
- DEVICE_LABEL = torch.cuda.get_device_name(
42
- torch.cuda.current_device()
43
- ).lower()
44
  else:
45
  DEVICE_LABEL = str(DEVICE).lower()
46
 
47
- print("Using device:", DEVICE)
48
-
49
- # ── load models ──────────────────────────────────────────────────────────────
50
  print("Loading 4B Distilled model (Standard VAE)...")
51
  pipe_standard = Flux2KleinPipeline.from_pretrained(
52
  "black-forest-labs/FLUX.2-klein-4B",
53
  torch_dtype=dtype,
54
- )
55
- pipe_standard.enable_model_cpu_offload()
56
 
57
  print("Loading Small Decoder VAE...")
58
  vae_small = AutoencoderKLFlux2.from_pretrained(
59
  "black-forest-labs/FLUX.2-small-decoder",
60
  torch_dtype=dtype,
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
- )
69
- pipe_small_decoder.enable_model_cpu_offload()
70
 
71
  pipe_lock_standard = threading.Lock()
72
  pipe_lock_small = threading.Lock()
73
 
74
- print("All models loaded.")
75
 
76
- # ── helpers ───────────────────────────────────────────────────────────────────
 
 
 
 
 
 
 
 
77
  def calc_dimensions(pil_img: Image.Image):
78
  iw, ih = pil_img.size
79
  aspect = iw / ih
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
  new_width = max(256, min(1024, round(new_width / 8) * 8))
87
  new_height = max(256, min(1024, round(new_height / 8) * 8))
88
  return new_width, new_height
89
 
 
 
 
90
 
91
- def parse_and_resize_images(paths: list[str], width: int, height: int):
92
- result = []
93
- for p in paths:
94
  try:
95
- img = Image.open(p).convert("RGB")
96
- result.append(img.resize((width, height), Image.LANCZOS))
97
  except Exception as e:
98
- print(f"Skipping image {p}: {e}")
99
- return result or None
100
-
101
-
102
- def image_to_base64(img: Image.Image) -> str:
103
- buf = io.BytesIO()
104
- img.save(buf, format="PNG")
105
- return base64.b64encode(buf.getvalue()).decode()
106
-
107
 
108
- def save_image(img: Image.Image, prefix: str = "output") -> str:
109
- filename = f"{prefix}_{uuid.uuid4().hex}.png"
110
- (OUTPUT_DIR / filename).parent.mkdir(parents=True, exist_ok=True)
111
- img.save(OUTPUT_DIR / filename, format="PNG")
112
- return filename
113
 
 
 
 
 
 
114
 
115
  def run_pipeline(pipe, lock, kwargs, seed):
116
  with lock:
117
- gen = torch.Generator(device="cpu").manual_seed(seed)
118
  result = pipe(**kwargs, generator=gen).images[0]
119
  return result
120
 
121
-
122
- def get_example_items():
123
- example_prompts = {
124
- "1.jpg": "Change the weather to stormy.",
125
- "2.jpg": "Transform the scene into a snowy winter day while preserving the original subject identity, framing, and composition.",
126
- "3.jpg": "Relight the image with soft golden sunset lighting while keeping all structures and subject details consistent.",
127
- "4.jpg": "Make the texture high-resolution.",
128
- }
129
- items = []
130
- if EXAMPLES_DIR.exists():
131
- for name in sorted(os.listdir(EXAMPLES_DIR)):
132
- if name.lower().endswith((".png", ".jpg", ".jpeg", ".webp")):
133
- items.append({
134
- "file": name,
135
- "url": f"/example-file/{name}",
136
- "prompt": example_prompts.get(
137
- name, "Edit this image while preserving composition."
138
- ),
139
- })
140
- return items
141
-
142
-
143
- # ── inference ─────────────────────────────────────────────────────────────────
144
  @spaces.GPU(duration=120)
145
  def infer(
146
- image_paths: list[str],
147
  prompt: str,
 
148
  seed: int,
149
  randomize_seed: bool,
150
  width: int,
151
  height: int,
152
- steps: int,
153
  guidance_scale: float,
154
  ):
155
  gc.collect()
156
  if torch.cuda.is_available():
157
  torch.cuda.empty_cache()
158
 
159
- if not prompt.strip():
160
  raise ValueError("Please enter a prompt.")
161
 
162
  if randomize_seed:
163
  seed = random.randint(0, MAX_SEED)
164
 
165
- if image_paths:
166
- first_pil = Image.open(image_paths[0]).convert("RGB")
 
167
  width, height = calc_dimensions(first_pil)
168
- image_list = parse_and_resize_images(image_paths, width, height)
169
- else:
170
- image_list = None
171
- width = max(256, min(MAX_IMAGE_SIZE, round(int(width) / 8) * 8))
172
- height = max(256, min(MAX_IMAGE_SIZE, round(int(height) / 8) * 8))
173
 
174
  shared_kwargs = dict(
175
  prompt=prompt,
176
  height=height,
177
  width=width,
178
- num_inference_steps=steps,
179
  guidance_scale=guidance_scale,
180
  )
181
- if image_list:
182
  shared_kwargs["image"] = image_list
183
 
 
184
  with concurrent.futures.ThreadPoolExecutor(max_workers=2) as executor:
185
  future_std = executor.submit(
186
  run_pipeline, pipe_standard, pipe_lock_standard, shared_kwargs, seed
@@ -203,35 +191,25 @@ def infer(
203
  return out_standard, out_small, seed
204
 
205
 
206
- # ── app ───────────────────────────────────────────────────────────────────────
207
- app = Server()
208
-
209
-
210
- @app.get("/example-file/{filename}")
211
- async def example_file(filename: str):
212
- path = EXAMPLES_DIR / filename
213
- if not path.exists():
214
- return JSONResponse({"error": "Not found"}, status_code=404)
215
- return FileResponse(path)
216
-
217
-
218
  @app.get("/download/{filename}")
219
  async def download_file(filename: str):
220
  path = OUTPUT_DIR / filename
221
  if not path.exists():
222
- return JSONResponse({"error": "Not found"}, status_code=404)
223
  return FileResponse(path, filename=filename, media_type="image/png")
224
 
225
-
226
  @app.post("/api/compare")
227
- async def compare(
228
- prompt: str = Form(...),
229
- seed: str = Form("0"),
230
- randomize_seed: str = Form("true"),
231
- width: str = Form("1024"),
232
- height: str = Form("1024"),
233
- steps: str = Form("4"),
234
- guidance_scale: str = Form("1.0"),
235
  images: Optional[List[UploadFile]] = File(None),
236
  ):
237
  temp_paths = []
@@ -239,36 +217,37 @@ async def compare(
239
  image_paths = []
240
  if images:
241
  for upload in images:
242
- suffix = Path(upload.filename).suffix or ".png"
 
 
243
  temp_name = f"upload_{uuid.uuid4().hex}{suffix}"
244
  temp_path = OUTPUT_DIR / temp_name
245
- content = await upload.read()
246
- temp_path.write_bytes(content)
 
247
  temp_paths.append(str(temp_path))
248
  image_paths.append(str(temp_path))
249
 
250
  out_std, out_small, used_seed = infer(
251
- image_paths = image_paths,
252
- prompt = prompt,
253
- seed = int(seed),
254
- randomize_seed = randomize_seed.lower() == "true",
255
- width = int(width),
256
- height = int(height),
257
- steps = int(steps),
258
- guidance_scale = float(guidance_scale),
259
  )
260
 
261
- fn_std = save_image(out_std, prefix="standard")
262
- fn_small = save_image(out_small, prefix="small")
263
 
264
  return JSONResponse({
265
- "success": True,
266
- "seed": used_seed,
267
- "standard_url": f"/download/{fn_std}",
268
- "small_url": f"/download/{fn_small}",
269
- "standard_base64": image_to_base64(out_std),
270
- "small_base64": image_to_base64(out_small),
271
- "device": DEVICE_LABEL,
272
  })
273
 
274
  except Exception as e:
@@ -282,1108 +261,451 @@ async def compare(
282
  pass
283
 
284
 
 
 
 
285
  @app.get("/", response_class=HTMLResponse)
286
  async def homepage(request: Request):
287
- examples = get_example_items()
288
- examples_json = json.dumps(examples)
289
-
290
- return f"""<!DOCTYPE html>
291
  <html lang="en">
292
  <head>
293
- <meta charset="UTF-8"/>
294
- <meta name="viewport" content="width=device-width, initial-scale=1.0"/>
295
- <title>Flux.2 Decoder Comparator</title>
296
  <style>
297
- @import url('https://fonts.googleapis.com/css2?family=Outfit:wght@400;500;600;700;800&display=swap');
298
 
299
  :root {{
300
- --bg: #0b0b10;
301
- --panel: #111218;
302
- --panel-2: #151621;
303
- --panel-3: #1b1d2a;
304
- --border: #242638;
305
- --muted: #9ca3af;
306
- --text: #f5f7fb;
307
- --text-dim: #c5cad3;
308
- --purple: #7c3aed;
309
- --purple-hover:#6d28d9;
310
- --purple-soft: rgba(124,58,237,0.14);
311
- --green: #22c55e;
312
- --green-soft: rgba(34,197,94,0.14);
313
- --red: #ef4444;
314
- --red-soft: rgba(239,68,68,0.14);
315
- --orange: #f97316;
316
- --orange-soft: rgba(249,115,22,0.14);
317
- --input-bg: #0f1017;
318
- }}
319
-
320
- *, *::before, *::after {{ box-sizing: border-box; border-radius: 0 !important; }}
321
 
322
  html, body {{
323
  margin: 0; padding: 0;
324
  background: var(--bg);
325
  color: var(--text);
326
- font-family: 'Outfit', sans-serif;
327
  min-height: 100%;
 
328
  }}
329
 
330
- body {{ overflow-x: hidden; }}
331
-
332
- .app-shell {{
333
- min-height: 100vh;
334
- background:
335
- linear-gradient(to bottom, rgba(124,58,237,0.08), transparent 160px),
336
- var(--bg);
337
- }}
338
-
339
- /* ── topbar ── */
340
  .topbar {{
341
- height: 56px;
342
- border-bottom: 1px solid var(--border);
343
- background: #0a0b11;
344
  display: flex;
345
  align-items: center;
346
  justify-content: center;
347
- color: #d7cdfc;
348
- font-size: 14px;
349
- font-weight: 600;
350
- letter-spacing: 0.02em;
 
351
  }}
352
 
353
- /* ── layout ── */
354
  .container {{
355
  max-width: 1440px;
356
  margin: 0 auto;
357
  padding: 28px;
358
  }}
359
 
360
- /* ── hero ── */
361
- .hero {{
362
- display: flex;
363
- align-items: flex-start;
364
- justify-content: space-between;
365
- gap: 16px;
366
- margin-bottom: 24px;
367
- padding-bottom: 20px;
368
- border-bottom: 1px solid var(--border);
369
- flex-wrap: wrap;
370
- }}
371
- .hero-left {{ display: flex; flex-direction: column; gap: 14px; }}
372
- .eyebrow {{ color: var(--muted); font-size: 13px; font-weight: 500; }}
373
- .title {{
374
- font-size: 44px; line-height: 1;
375
- font-weight: 800; margin: 0;
376
- letter-spacing: -0.03em;
377
- }}
378
- .hero-tags {{ display: flex; flex-wrap: wrap; gap: 10px; }}
379
- .tag {{
380
- display: inline-flex; align-items: center; gap: 8px;
381
- height: 34px; padding: 0 12px;
382
- border: 1px solid var(--border);
383
- font-size: 13px; font-weight: 700; letter-spacing: 0.01em;
384
- }}
385
- .tag svg {{ width: 15px; height: 15px; flex-shrink: 0; }}
386
- .tag-purple {{ color:#d8ccff; background:var(--purple-soft); border-color:rgba(124,58,237,0.35); }}
387
- .tag-green {{ color:#bbf7d0; background:var(--green-soft); border-color:rgba(34,197,94,0.35); }}
388
- .tag-orange {{ color:#fed7aa; background:var(--orange-soft); border-color:rgba(249,115,22,0.35); }}
389
- .hero-actions {{ display:flex; gap:10px; flex-shrink:0; }}
390
- .ghost-btn {{
391
- height:40px; padding:0 14px;
392
- border:1px solid var(--border);
393
- background:var(--panel); color:var(--text);
394
- font-family:'Outfit',sans-serif; font-size:14px; font-weight:600;
395
- cursor:pointer;
396
- }}
397
- .ghost-btn:hover {{ background:var(--panel-2); }}
398
 
399
- /* ── main grid ── */
400
- .main-grid {{
401
  display: grid;
402
- grid-template-columns: 420px 1fr;
403
  gap: 24px;
404
- align-items: start;
405
  }}
406
 
407
- /* ── panel ── */
408
  .panel {{
409
  background: var(--panel);
410
  border: 1px solid var(--border);
 
411
  display: flex;
412
  flex-direction: column;
413
  }}
 
414
  .panel-header {{
415
- height: 62px; min-height: 62px;
416
  border-bottom: 1px solid var(--border);
417
- display: flex; align-items: center;
418
- justify-content: space-between;
419
- padding: 0 18px;
420
- background: #101119;
421
- }}
422
- .panel-title {{
423
- font-size: 20px; font-weight: 700;
424
- letter-spacing: -0.02em; margin: 0;
425
  }}
426
- .panel-header-right {{
427
- display:flex; align-items:center; gap:8px;
428
- color:var(--muted); font-size:13px; font-weight:600;
429
- }}
430
- .status-pill {{
431
- padding:5px 8px;
432
- background:var(--panel-3);
433
- border:1px solid var(--border);
434
- color:var(--muted);
435
- font-size:12px; line-height:1;
436
- transition: all .2s;
437
- }}
438
- .status-pill.active {{
439
- background:rgba(245,158,11,0.12);
440
- border-color:rgba(245,158,11,0.35);
441
- color:#fbbf24;
442
- }}
443
- .panel-body {{ padding:18px; }}
444
-
445
- /* ── form ── */
446
- .form-stack {{ display:flex; flex-direction:column; gap:18px; }}
447
- .form-group {{ display:flex; flex-direction:column; gap:10px; }}
448
- .label {{
449
- font-size:14px; font-weight:600;
450
- color:var(--muted); letter-spacing:0.02em;
451
- }}
452
- .hint {{ color:var(--muted); font-size:13px; line-height:1.5; margin-top:-4px; }}
453
-
454
- textarea, input, button, select {{ font-family:'Outfit',sans-serif; }}
455
 
 
 
456
  .input, .textarea {{
457
- width:100%; background:var(--input-bg);
458
- border:1px solid var(--border);
459
- color:var(--text); outline:none;
460
- padding:14px; font-size:15px;
461
- }}
462
- .input:focus, .textarea:focus {{
463
- border-color:#3a3d56; background:#11131b;
464
  }}
465
- .textarea {{ min-height:120px; resize:vertical; line-height:1.55; }}
 
466
 
467
- /* ── upload ── */
468
  .upload-wrap {{
469
- background:var(--input-bg);
470
- border:1px dashed #32354b;
471
- min-height:180px;
472
- display:flex; flex-direction:column;
473
- gap:14px; padding:14px; cursor:pointer;
474
- }}
475
- .upload-wrap.dragover {{
476
- border-color:var(--purple);
477
- background:rgba(124,58,237,0.08);
478
- }}
479
- .upload-wrap input[type="file"] {{ display:none; }}
480
- .upload-placeholder {{
481
- min-height:150px;
482
- display:flex; flex-direction:column;
483
- align-items:center; justify-content:center;
484
- gap:14px; background:transparent;
485
- border:none; color:var(--text-dim);
486
- cursor:pointer; padding:16px; text-align:center;
487
- }}
488
- .upload-icon {{
489
- width:48px; height:48px;
490
- border:1px solid var(--border);
491
- background:var(--panel-2);
492
- display:flex; align-items:center;
493
- justify-content:center; color:#d8ccff;
494
- }}
495
- .preview-grid {{
496
- display:grid;
497
- grid-template-columns:repeat(auto-fill,minmax(88px,1fr));
498
- gap:10px;
499
- }}
500
- .thumb {{
501
- position:relative; aspect-ratio:1/1;
502
- overflow:hidden; border:1px solid var(--border);
503
- background:#0b0c12;
504
- }}
505
- .thumb img {{ width:100%; height:100%; object-fit:cover; display:block; }}
506
  .thumb-remove {{
507
- position:absolute; top:5px; right:5px;
508
- width:22px; height:22px;
509
- border:1px solid var(--border);
510
- background:rgba(11,11,16,0.88);
511
- color:white; cursor:pointer;
512
- display:flex; align-items:center;
513
- justify-content:center;
514
- font-size:14px; line-height:1;
515
  }}
516
 
517
- /* ── advanced ── */
518
- .advanced {{ border:1px solid var(--border); background:#0f1017; }}
519
  .advanced-toggle {{
520
- width:100%; height:48px;
521
- border:none; border-bottom:1px solid var(--border);
522
- background:transparent; color:var(--text);
523
- display:flex; align-items:center;
524
- justify-content:space-between;
525
- padding:0 14px; cursor:pointer;
526
- font-size:14px; font-weight:600;
527
- }}
528
- .advanced-toggle:hover {{ background:#121420; }}
529
- .advanced-body {{ display:none; padding:14px; }}
530
- .advanced-body.open {{ display:block; }}
531
- .advanced-grid {{
532
- display:grid;
533
- grid-template-columns:1fr 1fr;
534
- gap:14px;
535
- }}
536
- .checkbox-row {{
537
- margin-top:14px;
538
- display:flex; align-items:center;
539
- gap:10px; color:var(--text-dim);
540
- font-size:14px; font-weight:500;
541
- }}
542
- .checkbox-row input {{
543
- width:16px; height:16px;
544
- accent-color:var(--purple);
545
  }}
 
 
 
 
546
 
547
- /* ── actions ── */
548
- .actions {{
549
- display:grid;
550
- grid-template-columns:1fr 1fr;
551
- gap:12px; padding-top:8px;
552
- }}
553
  .btn {{
554
- height:48px; border:1px solid var(--border);
555
- background:var(--panel-2); color:var(--text);
556
- cursor:pointer; font-size:15px;
557
- font-weight:700; letter-spacing:0.01em;
558
- }}
559
- .btn:hover {{ background:#1a1d29; }}
560
- .btn-primary {{
561
- background:var(--purple);
562
- border-color:var(--purple); color:white;
563
- }}
564
- .btn-primary:hover {{
565
- background:var(--purple-hover);
566
- border-color:var(--purple-hover);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
567
  }}
568
 
569
- /* ── result panel ── */
570
- .result-shell {{ display:flex; flex-direction:column; gap:16px; }}
571
-
572
- /* ── image slider ── */
573
- .slider-wrap {{
574
- position:relative;
575
- width:100%;
576
- aspect-ratio:16/9;
577
- min-height:340px;
578
- border:1px solid var(--border);
579
- background:#0d0e14;
580
- overflow:hidden;
581
- user-select:none;
582
- }}
583
-
584
- /* empty state */
585
- .slider-empty {{
586
- position:absolute; inset:0;
587
- display:flex; flex-direction:column;
588
- align-items:center; justify-content:center;
589
- gap:14px; color:var(--text-dim);
590
- text-align:center; padding:24px;
591
- z-index:1;
592
- }}
593
- .slider-empty-box {{
594
- width:72px; height:72px;
595
- border:1px solid var(--border);
596
- background:var(--panel-2);
597
- display:flex; align-items:center;
598
- justify-content:center; color:#d8ccff;
599
- }}
600
-
601
- /* images */
602
- .slider-img {{
603
- position:absolute; inset:0;
604
- width:100%; height:100%;
605
- object-fit:contain;
606
- display:none;
607
- }}
608
- .slider-img.visible {{ display:block; }}
609
-
610
- /* clip the "before" (standard) image on the left */
611
- #imgBefore {{
612
- clip-path: inset(0 50% 0 0);
613
- z-index:2;
614
- }}
615
- #imgAfter {{
616
- z-index:1;
617
- }}
618
-
619
- /* divider line */
620
- .slider-divider {{
621
- position:absolute;
622
- top:0; bottom:0;
623
- left:50%;
624
- width:2px;
625
- background:rgba(255,255,255,0.7);
626
- z-index:5;
627
- pointer-events:none;
628
- display:none;
629
- }}
630
- .slider-divider.visible {{ display:block; }}
631
-
632
- /* handle */
633
- .slider-handle {{
634
- position:absolute;
635
- top:50%; left:50%;
636
- transform:translate(-50%,-50%);
637
- width:44px; height:44px;
638
- border:2px solid white;
639
- background:rgba(17,18,24,0.85);
640
- display:none;
641
- align-items:center; justify-content:center;
642
- z-index:6; cursor:ew-resize;
643
- box-shadow:0 2px 12px rgba(0,0,0,0.5);
644
- }}
645
- .slider-handle.visible {{ display:flex; }}
646
- .slider-handle svg {{ pointer-events:none; }}
647
-
648
- /* labels */
649
- .slider-label {{
650
- position:absolute;
651
- top:12px;
652
- padding:5px 10px;
653
- font-size:12px; font-weight:700;
654
- letter-spacing:0.04em;
655
- background:rgba(11,11,16,0.82);
656
- border:1px solid var(--border);
657
- color:var(--text);
658
- z-index:7;
659
- pointer-events:none;
660
- display:none;
661
- }}
662
- .slider-label.visible {{ display:block; }}
663
- #labelBefore {{ left:12px; color:#d8ccff; border-color:rgba(124,58,237,0.4); }}
664
- #labelAfter {{ right:12px; color:#bbf7d0; border-color:rgba(34,197,94,0.4); }}
665
-
666
- /* download buttons overlay */
667
- .dl-bar {{
668
- position:absolute; bottom:12px; right:12px;
669
- display:none; gap:8px; z-index:8;
670
- }}
671
- .dl-bar.visible {{ display:flex; }}
672
- .dl-btn {{
673
- height:34px; padding:0 10px;
674
- border:1px solid var(--border);
675
- background:rgba(17,18,24,0.88);
676
- color:white; font-family:'Outfit',sans-serif;
677
- font-size:12px; font-weight:700;
678
- text-decoration:none; cursor:pointer;
679
- display:flex; align-items:center; gap:6px;
680
- }}
681
-
682
- /* loader */
683
  .loader {{
684
- position:absolute; inset:0;
685
- display:none; align-items:center;
686
- justify-content:center; flex-direction:column;
687
- gap:14px;
688
- background:rgba(7,8,12,0.55);
689
- backdrop-filter:blur(8px);
690
- -webkit-backdrop-filter:blur(8px);
691
- z-index:10;
692
- }}
693
- .circle-loader {{
694
- width:58px; height:58px;
695
- border-radius:50% !important;
696
- border:4px solid rgba(255,255,255,0.14);
697
- border-top-color:#fff;
698
- border-right-color:#c4b5fd;
699
- animation:spin .9s linear infinite;
700
- box-shadow:0 0 20px rgba(124,58,237,0.18);
701
- }}
702
- .loader span {{
703
- font-size:14px; font-weight:600;
704
- color:#fff; letter-spacing:0.02em;
705
- }}
706
- @keyframes spin {{
707
- from {{ transform:rotate(0deg); }}
708
- to {{ transform:rotate(360deg); }}
709
  }}
 
 
710
 
711
- /* ── meta cards ── */
712
- .result-meta {{
713
- display:flex; gap:12px; flex-wrap:wrap;
714
- }}
715
- .meta-card {{
716
- border:1px solid var(--border);
717
- background:var(--panel-2);
718
- padding:12px 14px;
719
- flex:1 1 160px;
720
- }}
721
- .meta-label {{
722
- font-size:12px; color:var(--muted);
723
- letter-spacing:0.04em; margin-bottom:6px;
724
- }}
725
- .meta-value {{
726
- font-size:14px; font-weight:700;
727
- color:var(--text); word-break:break-word;
728
- line-height:1.45;
729
- }}
730
-
731
- /* ── examples ── */
732
- .examples-panel {{
733
- margin-top:24px;
734
- background:var(--panel);
735
- border:1px solid var(--border);
736
- overflow:hidden;
737
- }}
738
- .examples-header {{
739
- height:58px;
740
- border-bottom:1px solid var(--border);
741
- display:flex; align-items:center;
742
- padding:0 18px;
743
- font-size:20px; font-weight:700;
744
- background:#101119;
745
- }}
746
- .examples-body {{ padding:18px; }}
747
- .examples-grid {{
748
- display:grid;
749
- grid-template-columns:repeat(4,minmax(0,1fr));
750
- gap:14px;
751
- }}
752
- .example-card {{
753
- background:#0f1017;
754
- border:1px solid var(--border);
755
- cursor:pointer; overflow:hidden;
756
- }}
757
- .example-card:hover {{
758
- border-color:#3a3d56; background:#121420;
759
- }}
760
- .example-card img {{
761
- width:100%; aspect-ratio:1/1;
762
- object-fit:cover; display:block;
763
- border-bottom:1px solid var(--border);
764
- }}
765
- .example-body {{
766
- padding:12px;
767
- }}
768
- .example-body p {{
769
- margin:0; color:var(--text-dim);
770
- font-size:13px; line-height:1.5; font-weight:500;
771
- }}
772
-
773
- /* ── toast ── */
774
- .toast-wrap {{
775
- position:fixed; top:18px; right:18px;
776
- z-index:9999;
777
- display:flex; flex-direction:column; gap:10px;
778
- }}
779
- .toast {{
780
- min-width:260px; max-width:360px;
781
- background:#141623;
782
- border:1px solid var(--border);
783
- color:var(--text); padding:12px 14px;
784
- display:flex; align-items:flex-start;
785
- justify-content:space-between; gap:12px;
786
- box-shadow:0 10px 30px rgba(0,0,0,0.35);
787
- }}
788
- .toast button {{
789
- border:none; background:transparent;
790
- color:var(--text); font-size:18px;
791
- cursor:pointer; padding:0; line-height:1;
792
- }}
793
-
794
- /* ── responsive ── */
795
- @media(max-width:1100px) {{
796
- .main-grid {{ grid-template-columns:1fr; }}
797
- .examples-grid {{ grid-template-columns:repeat(2,minmax(0,1fr)); }}
798
- }}
799
- @media(max-width:640px) {{
800
- .container {{ padding:16px; }}
801
- .title {{ font-size:32px; }}
802
- .advanced-grid, .actions {{ grid-template-columns:1fr; }}
803
- .examples-grid {{ grid-template-columns:1fr; }}
804
- .result-meta {{ flex-direction:column; }}
805
  }}
806
  </style>
807
  </head>
808
  <body>
809
- <div class="toast-wrap" id="toastWrap"></div>
810
-
811
- <div class="app-shell">
812
- <div class="topbar">Flux.2 Klein 4B — Standard vs Small Decoder Comparison</div>
813
 
814
  <div class="container">
 
 
 
 
815
 
816
- <!-- hero -->
817
- <section class="hero">
818
- <div class="hero-left">
819
- <div class="eyebrow">black-forest-labs / flux.2-klein-4b / decoder-compare</div>
820
- <h1 class="title">Decoder Comparator</h1>
821
- <div class="hero-tags">
822
- <div class="tag tag-purple">
823
- <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
824
- <path d="M12 3v18"/><path d="M3 12h18"/>
825
- </svg>
826
- <span>Inference</span>
827
- </div>
828
- <div class="tag tag-green">
829
- <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
830
- <rect x="3" y="5" width="18" height="14"/>
831
- <path d="M8 13l2.5-2.5L13 13"/><path d="M13 13l2-2 3 3"/>
832
- </svg>
833
- <span>image-to-image</span>
834
- </div>
835
- <div class="tag tag-orange">
836
- <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
837
- <circle cx="12" cy="12" r="9"/>
838
- <path d="M8 12h8"/><path d="M12 8v8"/>
839
- </svg>
840
- <span>side-by-side slider</span>
841
- </div>
842
- </div>
843
- </div>
844
- <div class="hero-actions">
845
- <button class="ghost-btn" type="button"
846
- onclick="document.getElementById('examplesSection').scrollIntoView({{behavior:'smooth'}})">
847
- Examples
848
- </button>
849
- </div>
850
- </section>
851
-
852
- <!-- main grid -->
853
- <div class="main-grid">
854
-
855
- <!-- INPUT PANEL -->
856
  <div class="panel">
857
- <div class="panel-header">
858
- <h2 class="panel-title">Input</h2>
859
- <div class="panel-header-right">
860
- <span class="status-pill" id="inputStatus">Form</span>
861
- </div>
862
- </div>
863
  <div class="panel-body">
864
- <div class="form-stack">
865
-
866
- <div class="form-group">
867
- <div class="label">Images (optional)</div>
868
- <div class="upload-wrap" id="uploadZone">
869
- <input id="fileInput" type="file" accept="image/*" multiple/>
870
- <button class="upload-placeholder" id="uploadPlaceholder" type="button">
871
- <div class="upload-icon">
872
- <svg viewBox="0 0 24 24" width="24" height="24" fill="none"
873
- stroke="currentColor" stroke-width="1.8">
874
- <path d="M12 4v10"/>
875
- <path d="M8.5 7.5 12 4l3.5 3.5"/>
876
- <path d="M4 16.5h16"/><path d="M6 20h12"/>
877
- </svg>
878
- </div>
879
- <div>
880
- <div style="font-weight:700;color:var(--text);margin-bottom:4px;">
881
- Upload one or more images
882
- </div>
883
- <div style="font-size:13px;color:var(--muted);">Drag & drop or click to browse</div>
884
- </div>
885
- </button>
886
- <div class="preview-grid" id="previewGrid" style="display:none;"></div>
887
- </div>
888
- <div class="hint">
889
- First image sets width &amp; height automatically (aspect-ratio preserved, max 1024 px).
890
  </div>
 
891
  </div>
 
892
 
893
- <div class="form-group">
894
- <label class="label" for="prompt">Prompt</label>
895
- <textarea id="prompt" class="textarea"
896
- placeholder="Describe what you want to generate or edit…"></textarea>
897
- </div>
898
 
899
- <!-- advanced -->
900
- <div class="advanced">
901
- <button class="advanced-toggle" id="advancedToggle" type="button">
902
- <span>Advanced Settings</span>
903
- <span id="advancedIcon" style="font-size:22px;font-weight:700;line-height:1;">+</span>
904
- </button>
905
- <div class="advanced-body" id="advancedBody">
906
- <div class="advanced-grid">
907
- <div class="form-group">
908
- <label class="label" for="seed">seed</label>
909
- <input id="seed" class="input" type="number" min="0" max="{MAX_SEED}" value="0"/>
910
- </div>
911
- <div class="form-group">
912
- <label class="label" for="steps">steps</label>
913
- <input id="steps" class="input" type="number" min="1" max="20" value="4"/>
914
- </div>
915
- <div class="form-group">
916
- <label class="label" for="width">width</label>
917
- <input id="width" class="input" type="number" min="256" max="{MAX_IMAGE_SIZE}" step="8" value="1024"/>
918
- </div>
919
- <div class="form-group">
920
- <label class="label" for="height">height</label>
921
- <input id="height" class="input" type="number" min="256" max="{MAX_IMAGE_SIZE}" step="8" value="1024"/>
922
- </div>
923
- <div class="form-group">
924
- <label class="label" for="guidance">guidance scale</label>
925
- <input id="guidance" class="input" type="number" min="0" max="10" step="0.1" value="1.0"/>
926
- </div>
927
- </div>
928
- <div class="checkbox-row">
929
- <input id="randomizeSeed" type="checkbox" checked/>
930
- <label for="randomizeSeed">Randomize seed</label>
931
- </div>
932
  </div>
 
 
 
933
  </div>
 
934
 
935
- <div class="actions">
936
- <button class="btn btn-primary" id="runBtn" type="button">Compare</button>
937
- <button class="btn" id="clearBtn" type="button">Clear</button>
938
- </div>
939
-
940
  </div>
941
  </div>
942
- </div><!-- /input panel -->
943
 
944
- <!-- RESULT PANEL -->
945
  <div class="panel">
946
  <div class="panel-header">
947
- <h2 class="panel-title">Result</h2>
948
- <div class="panel-header-right">
949
- <span style="font-size:13px;color:var(--muted);">drag the handle to compare</span>
950
- <span class="status-pill" id="resultStatus">Idle</span>
951
- </div>
952
  </div>
953
- <div class="panel-body">
954
  <div class="result-shell">
 
 
 
 
 
 
 
955
 
956
- <!-- ── image comparison slider ── -->
957
- <div class="slider-wrap" id="sliderWrap">
958
-
959
- <!-- empty placeholder -->
960
- <div class="slider-empty" id="sliderEmpty">
961
- <div class="slider-empty-box">
962
- <svg viewBox="0 0 24 24" width="30" height="30" fill="none"
963
- stroke="currentColor" stroke-width="1.8">
964
- <rect x="4" y="5" width="16" height="11"/>
965
- <path d="M8 12l2.5-2.5L13 12"/><path d="M13 12l2-2 2 2"/>
966
- <path d="M12 16v4"/>
967
- </svg>
968
- </div>
969
- <div>
970
- <div style="font-size:17px;font-weight:700;color:var(--text);margin-bottom:4px;">
971
- No output yet
972
- </div>
973
- <div style="font-size:14px;color:var(--muted);">
974
- Both results will appear here as a draggable comparison
975
- </div>
976
  </div>
977
  </div>
 
 
978
 
979
- <!-- after = small decoder (right / background) -->
980
- <img id="imgAfter" class="slider-img" alt="Small Decoder output"/>
981
- <!-- before = standard decoder (left / foreground, clipped) -->
982
- <img id="imgBefore" class="slider-img" alt="Standard Decoder output"/>
983
-
984
- <!-- divider -->
985
- <div class="slider-divider" id="sliderDivider"></div>
986
-
987
- <!-- handle -->
988
- <div class="slider-handle" id="sliderHandle">
989
- <svg viewBox="0 0 24 24" width="22" height="22" fill="none"
990
- stroke="white" stroke-width="2.2">
991
- <path d="M8 5l-5 7 5 7"/><path d="M16 5l5 7-5 7"/>
992
- </svg>
993
- </div>
994
-
995
- <!-- labels -->
996
- <div class="slider-label" id="labelBefore">Standard Decoder</div>
997
- <div class="slider-label" id="labelAfter">Small Decoder</div>
998
-
999
- <!-- download bar -->
1000
- <div class="dl-bar" id="dlBar">
1001
- <a class="dl-btn" id="dlStandard" download title="Download standard">
1002
- <svg viewBox="0 0 24 24" width="14" height="14" fill="none"
1003
- stroke="currentColor" stroke-width="2.4">
1004
- <path d="M12 4v10"/><path d="m7.5 10.5 4.5 4.5 4.5-4.5"/>
1005
- <path d="M5 20h14"/>
1006
- </svg>
1007
- Standard
1008
- </a>
1009
- <a class="dl-btn" id="dlSmall" download title="Download small">
1010
- <svg viewBox="0 0 24 24" width="14" height="14" fill="none"
1011
- stroke="currentColor" stroke-width="2.4">
1012
- <path d="M12 4v10"/><path d="m7.5 10.5 4.5 4.5 4.5-4.5"/>
1013
- <path d="M5 20h14"/>
1014
- </svg>
1015
- Small
1016
- </a>
1017
- </div>
1018
-
1019
- <!-- loader -->
1020
- <div class="loader" id="loaderOverlay">
1021
- <div class="circle-loader"></div>
1022
- <span>Running both pipelines…</span>
1023
- </div>
1024
-
1025
- </div><!-- /slider-wrap -->
1026
-
1027
- <!-- meta -->
1028
- <div class="result-meta">
1029
- <div class="meta-card">
1030
- <div class="meta-label">seed used</div>
1031
- <div class="meta-value" id="usedSeed">—</div>
1032
- </div>
1033
- <div class="meta-card">
1034
- <div class="meta-label">device</div>
1035
- <div class="meta-value" id="deviceValue">{DEVICE_LABEL}</div>
1036
- </div>
1037
- <div class="meta-card">
1038
- <div class="meta-label">slider position</div>
1039
- <div class="meta-value" id="sliderPct">50 %</div>
1040
- </div>
1041
  </div>
1042
 
1043
  </div>
1044
  </div>
1045
- </div><!-- /result panel -->
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1046
 
1047
- </div><!-- /main-grid -->
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1048
 
1049
- <!-- examples -->
1050
- <section class="examples-panel" id="examplesSection">
1051
- <div class="examples-header">Examples</div>
1052
- <div class="examples-body">
1053
- <div class="examples-grid" id="examplesGrid"></div>
1054
- </div>
1055
- </section>
1056
-
1057
- </div><!-- /container -->
1058
- </div><!-- /app-shell -->
1059
-
1060
- <script>
1061
- /* ─── data ──────────────────────────────────────��──────────────────────────── */
1062
- const examples = {examples_json};
1063
-
1064
- /* ─── state ─────────────────────────────────────────────────────────────────── */
1065
- const state = {{
1066
- files: [],
1067
- advancedOpen: false,
1068
- sliderPct: 50,
1069
- dragging: false,
1070
- }};
1071
-
1072
- /* ─── element refs ──────────────────────────────────────────────────────────── */
1073
- const uploadZone = document.getElementById("uploadZone");
1074
- const fileInput = document.getElementById("fileInput");
1075
- const uploadPlaceholder = document.getElementById("uploadPlaceholder");
1076
- const previewGrid = document.getElementById("previewGrid");
1077
-
1078
- const promptEl = document.getElementById("prompt");
1079
- const seedEl = document.getElementById("seed");
1080
- const stepsEl = document.getElementById("steps");
1081
- const widthEl = document.getElementById("width");
1082
- const heightEl = document.getElementById("height");
1083
- const guidanceEl = document.getElementById("guidance");
1084
- const randomizeSeedEl = document.getElementById("randomizeSeed");
1085
-
1086
- const advancedToggle = document.getElementById("advancedToggle");
1087
- const advancedBody = document.getElementById("advancedBody");
1088
- const advancedIcon = document.getElementById("advancedIcon");
1089
-
1090
- const runBtn = document.getElementById("runBtn");
1091
- const clearBtn = document.getElementById("clearBtn");
1092
-
1093
- const resultStatus = document.getElementById("resultStatus");
1094
- const loaderOverlay = document.getElementById("loaderOverlay");
1095
- const toastWrap = document.getElementById("toastWrap");
1096
-
1097
- /* slider elements */
1098
- const sliderWrap = document.getElementById("sliderWrap");
1099
- const sliderEmpty = document.getElementById("sliderEmpty");
1100
- const imgBefore = document.getElementById("imgBefore"); // standard
1101
- const imgAfter = document.getElementById("imgAfter"); // small
1102
- const sliderDivider = document.getElementById("sliderDivider");
1103
- const sliderHandle = document.getElementById("sliderHandle");
1104
- const labelBefore = document.getElementById("labelBefore");
1105
- const labelAfter = document.getElementById("labelAfter");
1106
- const dlBar = document.getElementById("dlBar");
1107
- const dlStandard = document.getElementById("dlStandard");
1108
- const dlSmall = document.getElementById("dlSmall");
1109
-
1110
- const usedSeedEl = document.getElementById("usedSeed");
1111
- const deviceValueEl = document.getElementById("deviceValue");
1112
- const sliderPctEl = document.getElementById("sliderPct");
1113
-
1114
- /* ─── toast ─────────────────────────────────────────────────────────────────── */
1115
- function showToast(msg) {{
1116
- const t = document.createElement("div");
1117
- t.className = "toast";
1118
- const txt = document.createElement("div");
1119
- txt.textContent = msg;
1120
- const btn = document.createElement("button");
1121
- btn.type = "button";
1122
- btn.innerHTML = "&times;";
1123
- btn.addEventListener("click", () => t.remove());
1124
- t.appendChild(txt);
1125
- t.appendChild(btn);
1126
- toastWrap.appendChild(t);
1127
- setTimeout(() => t.remove(), 4500);
1128
- }}
1129
-
1130
- /* ─── status ─────────────────────────────────────────────────────────────────── */
1131
- function setResultStatus(active) {{
1132
- resultStatus.textContent = active ? "Active" : "Idle";
1133
- resultStatus.classList.toggle("active", active);
1134
- }}
1135
-
1136
- function setLoading(loading) {{
1137
- loaderOverlay.style.display = loading ? "flex" : "none";
1138
- runBtn.disabled = loading;
1139
- clearBtn.disabled = loading;
1140
- runBtn.style.opacity = clearBtn.style.opacity = loading ? "0.8" : "1";
1141
- runBtn.style.cursor = clearBtn.style.cursor = loading ? "not-allowed" : "pointer";
1142
- setResultStatus(loading);
1143
- }}
1144
-
1145
- /* ─── advanced ───────────────────────────────────────────────────────────────── */
1146
- function setAdvanced(open) {{
1147
- state.advancedOpen = open;
1148
- advancedBody.classList.toggle("open", open);
1149
- advancedIcon.textContent = open ? "−" : "+";
1150
- }}
1151
- advancedToggle.addEventListener("click", () => setAdvanced(!state.advancedOpen));
1152
-
1153
- /* ─── slider logic ───────────────────────────────────────────────────────────── */
1154
- function applySlider(pct) {{
1155
- state.sliderPct = pct;
1156
- const p = pct.toFixed(1);
1157
-
1158
- /* clip the "before" (standard) image so only left portion shows */
1159
- imgBefore.style.clipPath = `inset(0 ${{(100 - pct).toFixed(1)}}% 0 0)`;
1160
-
1161
- /* position divider & handle */
1162
- sliderDivider.style.left = p + "%";
1163
- sliderHandle.style.left = p + "%";
1164
-
1165
- /* update meta */
1166
- sliderPctEl.textContent = Math.round(pct) + " %";
1167
- }}
1168
-
1169
- function pctFromEvent(e) {{
1170
- const rect = sliderWrap.getBoundingClientRect();
1171
- const clientX = e.touches ? e.touches[0].clientX : e.clientX;
1172
- return Math.max(0, Math.min(100, ((clientX - rect.left) / rect.width) * 100));
1173
- }}
1174
-
1175
- sliderHandle.addEventListener("mousedown", (e) => {{ state.dragging = true; e.preventDefault(); }});
1176
- sliderHandle.addEventListener("touchstart", (e) => {{ state.dragging = true; }}, {{ passive: true }});
1177
-
1178
- window.addEventListener("mousemove", (e) => {{
1179
- if (!state.dragging) return;
1180
- applySlider(pctFromEvent(e));
1181
- }});
1182
- window.addEventListener("touchmove", (e) => {{
1183
- if (!state.dragging) return;
1184
- applySlider(pctFromEvent(e));
1185
- }}, {{ passive: true }});
1186
-
1187
- window.addEventListener("mouseup", () => {{ state.dragging = false; }});
1188
- window.addEventListener("touchend", () => {{ state.dragging = false; }});
1189
-
1190
- /* also allow clicking anywhere on the wrap to jump */
1191
- sliderWrap.addEventListener("click", (e) => {{
1192
- if (e.target === sliderHandle) return;
1193
- if (!imgBefore.classList.contains("visible")) return;
1194
- applySlider(pctFromEvent(e));
1195
- }});
1196
-
1197
- function showSlider(standardUrl, smallUrl) {{
1198
- sliderEmpty.style.display = "none";
1199
-
1200
- imgBefore.src = standardUrl;
1201
- imgAfter.src = smallUrl;
1202
- imgBefore.classList.add("visible");
1203
- imgAfter.classList.add("visible");
1204
-
1205
- sliderDivider.classList.add("visible");
1206
- sliderHandle.classList.add("visible");
1207
- labelBefore.classList.add("visible");
1208
- labelAfter.classList.add("visible");
1209
- dlBar.classList.add("visible");
1210
-
1211
- applySlider(50);
1212
- }}
1213
-
1214
- /* ─── upload ─────────────────────────────────────────────────────────────────── */
1215
- function createThumb(file, index) {{
1216
- const wrap = document.createElement("div");
1217
- wrap.className = "thumb";
1218
- const img = document.createElement("img");
1219
- img.src = URL.createObjectURL(file);
1220
- img.alt = file.name;
1221
- const rm = document.createElement("button");
1222
- rm.type = "button";
1223
- rm.className = "thumb-remove";
1224
- rm.innerHTML = "&times;";
1225
- rm.addEventListener("click", (e) => {{
1226
- e.stopPropagation();
1227
- state.files.splice(index, 1);
1228
- renderPreviews();
1229
- }});
1230
- wrap.appendChild(img);
1231
- wrap.appendChild(rm);
1232
- return wrap;
1233
- }}
1234
-
1235
- function renderPreviews() {{
1236
- previewGrid.innerHTML = "";
1237
- if (!state.files.length) {{
1238
- uploadPlaceholder.style.display = "flex";
1239
- previewGrid.style.display = "none";
1240
- return;
1241
- }}
1242
- uploadPlaceholder.style.display = "none";
1243
- previewGrid.style.display = "grid";
1244
- state.files.forEach((f, i) => previewGrid.appendChild(createThumb(f, i)));
1245
- }}
1246
-
1247
- function addFiles(list) {{
1248
- const valid = Array.from(list).filter(f => f.type.startsWith("image/"));
1249
- if (!valid.length) {{ showToast("Please upload valid image files."); return; }}
1250
- state.files = [...state.files, ...valid];
1251
- renderPreviews();
1252
- }}
1253
-
1254
- uploadPlaceholder.addEventListener("click", () => fileInput.click());
1255
- uploadZone.addEventListener("click", (e) => {{ if (e.target === uploadZone) fileInput.click(); }});
1256
- fileInput.addEventListener("change", (e) => {{ addFiles(e.target.files); fileInput.value = ""; }});
1257
-
1258
- uploadZone.addEventListener("dragover", (e) => {{ e.preventDefault(); uploadZone.classList.add("dragover"); }});
1259
- uploadZone.addEventListener("dragleave", () => uploadZone.classList.remove("dragover"));
1260
- uploadZone.addEventListener("drop", (e) => {{
1261
- e.preventDefault();
1262
- uploadZone.classList.remove("dragover");
1263
- if (e.dataTransfer.files?.length) addFiles(e.dataTransfer.files);
1264
- }});
1265
-
1266
- /* ─── clear ──────────────────────────────────────────────────────────────────── */
1267
- function clearAll() {{
1268
- state.files = [];
1269
- renderPreviews();
1270
- promptEl.value = "";
1271
- seedEl.value = "0"; stepsEl.value = "4";
1272
- widthEl.value = "1024"; heightEl.value = "1024";
1273
- guidanceEl.value = "1.0";
1274
- randomizeSeedEl.checked = true;
1275
-
1276
- imgBefore.classList.remove("visible"); imgBefore.removeAttribute("src");
1277
- imgAfter.classList.remove("visible"); imgAfter.removeAttribute("src");
1278
- sliderDivider.classList.remove("visible");
1279
- sliderHandle.classList.remove("visible");
1280
- labelBefore.classList.remove("visible");
1281
- labelAfter.classList.remove("visible");
1282
- dlBar.classList.remove("visible");
1283
- sliderEmpty.style.display = "flex";
1284
-
1285
- usedSeedEl.textContent = "—";
1286
- deviceValueEl.textContent = "{DEVICE_LABEL}";
1287
- sliderPctEl.textContent = "50 %";
1288
-
1289
- setResultStatus(false);
1290
- setAdvanced(false);
1291
- setLoading(false);
1292
- }}
1293
- clearBtn.addEventListener("click", clearAll);
1294
-
1295
- /* ─── examples ────────��──────────────────────────────────────────────────────── */
1296
- async function fileFromUrl(url, name = "example.jpg") {{
1297
- const res = await fetch(url);
1298
- if (!res.ok) throw new Error("Failed to fetch example image.");
1299
- const blob = await res.blob();
1300
- return new File([blob], name, {{ type: blob.type || "image/jpeg" }});
1301
- }}
1302
-
1303
- function renderExamples() {{
1304
- const grid = document.getElementById("examplesGrid");
1305
- grid.innerHTML = "";
1306
- examples.forEach(item => {{
1307
- const card = document.createElement("div");
1308
- card.className = "example-card";
1309
- const img = document.createElement("img");
1310
- img.src = item.url; img.alt = item.file;
1311
- const body = document.createElement("div");
1312
- body.className = "example-body";
1313
- const p = document.createElement("p");
1314
- p.textContent = item.prompt;
1315
- body.appendChild(p);
1316
- card.appendChild(img);
1317
- card.appendChild(body);
1318
- card.addEventListener("click", async () => {{
1319
  try {{
1320
- const f = await fileFromUrl(item.url, item.file);
1321
- state.files = [f];
1322
- renderPreviews();
1323
- promptEl.value = item.prompt;
1324
- showToast("Example loaded.");
 
 
 
 
 
 
 
 
 
 
 
 
 
1325
  }} catch(err) {{
1326
- showToast(err.message || "Failed to load example.");
 
 
 
1327
  }}
1328
- }});
1329
- grid.appendChild(card);
1330
- }});
1331
- }}
1332
-
1333
- /* ─── submit ─────────────────────────────────────────────────────────────────── */
1334
- async function submitCompare() {{
1335
- const prompt = promptEl.value.trim();
1336
- if (!prompt) {{ showToast("Please enter a prompt."); return; }}
1337
-
1338
- const fd = new FormData();
1339
- fd.append("prompt", prompt);
1340
- fd.append("seed", seedEl.value || "0");
1341
- fd.append("randomize_seed", String(randomizeSeedEl.checked));
1342
- fd.append("width", widthEl.value || "1024");
1343
- fd.append("height", heightEl.value || "1024");
1344
- fd.append("steps", stepsEl.value || "4");
1345
- fd.append("guidance_scale", guidanceEl.value || "1.0");
1346
- state.files.forEach(f => fd.append("images", f));
1347
-
1348
- setLoading(true);
1349
- try {{
1350
- const res = await fetch("/api/compare", {{ method:"POST", body:fd }});
1351
- const data = await res.json();
1352
- if (!res.ok || !data.success) throw new Error(data.error || "Processing failed.");
1353
-
1354
- /* show slider */
1355
- showSlider(
1356
- data.standard_url + "?t=" + Date.now(),
1357
- data.small_url + "?t=" + Date.now()
1358
- );
1359
-
1360
- dlStandard.href = data.standard_url;
1361
- dlSmall.href = data.small_url;
1362
-
1363
- usedSeedEl.textContent = String(data.seed);
1364
- deviceValueEl.textContent = (data.device || "{DEVICE_LABEL}").toLowerCase();
1365
-
1366
- }} catch(err) {{
1367
- showToast(err.message || "An unexpected error occurred.");
1368
- }} finally {{
1369
- setLoading(false);
1370
- }}
1371
- }}
1372
-
1373
- runBtn.addEventListener("click", submitCompare);
1374
- promptEl.addEventListener("keydown", e => {{
1375
- if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) submitCompare();
1376
- }});
1377
-
1378
- /* ─── init ───────────────────────────────────────────────────────────────────── */
1379
- setAdvanced(false);
1380
- setResultStatus(false);
1381
- renderExamples();
1382
- renderPreviews();
1383
- applySlider(50);
1384
- </script>
1385
- </body>
1386
- </html>"""
1387
 
 
 
 
 
1388
 
1389
  app.launch()
 
1
  import os
 
2
  import io
3
+ import gc
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
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())
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
+ # -------------------------------------------------------------------------
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():
144
  torch.cuda.empty_cache()
145
 
146
+ if not prompt or not prompt.strip():
147
  raise ValueError("Please enter a prompt.")
148
 
149
  if randomize_seed:
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,
163
  height=height,
164
  width=width,
165
+ num_inference_steps=num_inference_steps,
166
  guidance_scale=guidance_scale,
167
  )
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
 
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
200
  if not path.exists():
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"),
209
+ width: str = Form("1024"),
210
+ height: str = Form("1024"),
211
+ steps: str = Form("4"),
212
+ guidance: str = Form("1.0"),
213
  images: Optional[List[UploadFile]] = File(None),
214
  ):
215
  temp_paths = []
 
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
225
+ content = await upload.read()
226
+ with open(temp_path, "wb") as f:
227
+ f.write(content)
228
  temp_paths.append(str(temp_path))
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:
 
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>
709
+ """
710
 
711
  app.launch()