prithivMLmods commited on
Commit
3d7021d
·
verified ·
1 Parent(s): 4741a7b

Update app.py

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