prithivMLmods commited on
Commit
fd7d7e3
·
verified ·
1 Parent(s): 440ada4

Update app.py

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