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

Update app.py

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