prithivMLmods commited on
Commit
886219d
·
verified ·
1 Parent(s): 0d2afbe

Update app.py

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