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

Update app.py

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