prithivMLmods commited on
Commit
4c20639
·
verified ·
1 Parent(s): fd7d7e3

Update app.py

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