tomkay commited on
Commit
26612fd
Β·
verified Β·
1 Parent(s): 949365d

Upload webapp.py with huggingface_hub

Browse files
Files changed (1) hide show
  1. webapp.py +1005 -0
webapp.py ADDED
@@ -0,0 +1,1005 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Standalone web UI for LTX-2.3 video generation.
2
+
3
+ Designed to be distributed inside a HuggingFace model repo. Place this file
4
+ anywhere in the downloaded repo directory (or a sibling directory) and run:
5
+
6
+ python webapp_standalone.py
7
+ python webapp_standalone.py --port 8080
8
+ python webapp_standalone.py --compare-dir /path/to/second/model
9
+ python webapp_standalone.py --model-name "LTX-2.3 24 GB RAM"
10
+
11
+ The script auto-detects its own directory as the primary model. A second
12
+ model can be supplied via --compare-dir for A/B comparison.
13
+
14
+ Requirements:
15
+ pip install flask
16
+ pip install mlx mlx-lm ltx-core-mlx ltx-pipelines-mlx
17
+ """
18
+
19
+ import argparse
20
+ import json
21
+ import subprocess
22
+ import sys
23
+ import threading
24
+ import time
25
+ import uuid
26
+ from collections import defaultdict
27
+ from pathlib import Path
28
+
29
+ from flask import Flask, Response, jsonify, request, send_file
30
+
31
+ # ---------------------------------------------------------------------------
32
+ # CLI args β€” parsed at import time so the constants below can reference them
33
+ # ---------------------------------------------------------------------------
34
+
35
+ def _build_arg_parser() -> argparse.ArgumentParser:
36
+ p = argparse.ArgumentParser(
37
+ description="LTX-2.3 standalone video-generation web UI"
38
+ )
39
+ p.add_argument("--port", type=int, default=7860,
40
+ help="Port to listen on (default: 7860)")
41
+ p.add_argument("--compare-dir", type=str, default=None,
42
+ help="Optional path to a second model directory for A/B comparison")
43
+ p.add_argument("--model-name", type=str, default=None,
44
+ help="Display name for the primary model (default: directory name)")
45
+ return p
46
+
47
+
48
+ # Parse only our own args; anything unrecognised is left alone so Flask's own
49
+ # dev-server reloader doesn't choke on our flags.
50
+ _parser = _build_arg_parser()
51
+ _args, _unknown = _parser.parse_known_args()
52
+
53
+ # ---------------------------------------------------------------------------
54
+ # Model discovery
55
+ # ---------------------------------------------------------------------------
56
+
57
+ # The primary model IS the directory that contains this script β€” i.e. the
58
+ # downloaded HuggingFace repo root.
59
+ PRIMARY_DIR = Path(__file__).parent.resolve()
60
+ PRIMARY_NAME = _args.model_name or PRIMARY_DIR.name
61
+
62
+ COMPARE_DIR: Path | None = Path(_args.compare_dir).resolve() if _args.compare_dir else None
63
+ COMPARE_NAME: str | None = COMPARE_DIR.name if COMPARE_DIR else None
64
+
65
+ # Required files that signal a valid, ready model directory.
66
+ _REQUIRED_FILES = [
67
+ "transformer-distilled.safetensors",
68
+ "connector.safetensors",
69
+ "vae_decoder.safetensors",
70
+ "audio_vae.safetensors",
71
+ "vocoder.safetensors",
72
+ ]
73
+
74
+
75
+ def _model_ready(path: Path) -> bool:
76
+ return path.is_dir() and all((path / f).exists() for f in _REQUIRED_FILES)
77
+
78
+
79
+ def _model_missing_files(path: Path) -> list[str]:
80
+ return [f for f in _REQUIRED_FILES if not (path / f).exists()]
81
+
82
+
83
+ # Build the static model list once at startup.
84
+ MODELS: list[dict] = []
85
+
86
+ _primary_ok = _model_ready(PRIMARY_DIR)
87
+ MODELS.append({
88
+ "id": "primary",
89
+ "label": PRIMARY_NAME,
90
+ "dir": str(PRIMARY_DIR),
91
+ "ready": _primary_ok,
92
+ "missing": _model_missing_files(PRIMARY_DIR) if not _primary_ok else [],
93
+ })
94
+
95
+ if COMPARE_DIR is not None:
96
+ _compare_ok = _model_ready(COMPARE_DIR)
97
+ MODELS.append({
98
+ "id": "compare",
99
+ "label": COMPARE_NAME,
100
+ "dir": str(COMPARE_DIR),
101
+ "ready": _compare_ok,
102
+ "missing": _model_missing_files(COMPARE_DIR) if not _compare_ok else [],
103
+ })
104
+
105
+ # Convenience lookup: id β†’ dir
106
+ MODEL_DIRS: dict[str, str] = {m["id"]: m["dir"] for m in MODELS}
107
+
108
+ # ---------------------------------------------------------------------------
109
+ # Paths
110
+ # ---------------------------------------------------------------------------
111
+
112
+ # Videos are saved alongside this script (which lives in the model repo).
113
+ RESULTS_DIR = PRIMARY_DIR / "webapp_videos"
114
+ RESULTS_DIR.mkdir(parents=True, exist_ok=True)
115
+
116
+ # generate_ltx.py is located relative to the RAM/RUN working tree. We find
117
+ # it by searching upward from this file, then falling back to a path the user
118
+ # can override via the GENERATE_SCRIPT env var.
119
+ import os as _os
120
+
121
+ def _find_generate_script() -> Path:
122
+ env_override = _os.environ.get("GENERATE_SCRIPT")
123
+ if env_override:
124
+ return Path(env_override)
125
+ # Walk up looking for experiments/flux_phase1/generate_ltx.py
126
+ cur = Path(__file__).parent
127
+ for _ in range(6):
128
+ candidate = cur / "experiments" / "flux_phase1" / "generate_ltx.py"
129
+ if candidate.exists():
130
+ return candidate
131
+ cur = cur.parent
132
+ # Last resort: assume this script is inside RAM/RUN/results/<something>/
133
+ # so climb two levels to RAM/RUN/
134
+ return Path(__file__).parent.parent.parent / "experiments" / "flux_phase1" / "generate_ltx.py"
135
+
136
+
137
+ GENERATE_SCRIPT = _find_generate_script()
138
+
139
+ # ---------------------------------------------------------------------------
140
+ # Job state
141
+ # ---------------------------------------------------------------------------
142
+
143
+ # job_id β†’ {status, log_lines, video_path, started, finished, pid, params}
144
+ JOBS: dict = {}
145
+ JOBS_LOCK = threading.Lock()
146
+
147
+ # ---------------------------------------------------------------------------
148
+ # apply_mixed_precision_quantization
149
+ # (kept here so the script is self-contained; also used by generate_ltx.py
150
+ # which is invoked as a subprocess β€” but having it here lets us surface the
151
+ # logic for anyone reading this file)
152
+ # ---------------------------------------------------------------------------
153
+
154
+ def apply_mixed_precision_quantization(model, weights, group_size: int = 64) -> None:
155
+ """Per-layer mixed-precision quantization from a weight dict.
156
+
157
+ Unlike ltx_core_mlx's apply_quantization (which uses a single detected
158
+ bit width for all layers), this version detects each layer's bits from
159
+ its packed weight shape and applies nn.quantize once per unique bit width.
160
+ """
161
+ import mlx.nn as nn
162
+
163
+ layer_bits: dict[str, int] = {}
164
+ for key in weights:
165
+ if not key.endswith(".scales"):
166
+ continue
167
+ layer = key[: -len(".scales")]
168
+ w_key = layer + ".weight"
169
+ if w_key not in weights:
170
+ continue
171
+ w_cols = weights[w_key].shape[-1]
172
+ s_cols = weights[key].shape[-1]
173
+ bits = round(w_cols * 32 / (s_cols * group_size))
174
+ if bits in (2, 3, 4, 5, 6, 8):
175
+ layer_bits[layer] = bits
176
+
177
+ if not layer_bits:
178
+ return
179
+
180
+ bits_to_layers: dict[int, set] = defaultdict(set)
181
+ for layer, b in layer_bits.items():
182
+ bits_to_layers[b].add(layer)
183
+
184
+ for bits, layers in sorted(bits_to_layers.items()):
185
+ def _predicate(path: str, module, _layers=layers) -> bool:
186
+ return path in _layers and isinstance(module, nn.Linear)
187
+ nn.quantize(model, group_size=group_size, bits=bits, class_predicate=_predicate)
188
+
189
+ total = sum(len(v) for v in bits_to_layers.values())
190
+ dist = {b: len(v) for b, v in sorted(bits_to_layers.items())}
191
+ print(f" Mixed-precision quantization: {total} layers β€” {dist}", flush=True)
192
+
193
+ # ---------------------------------------------------------------------------
194
+ # Job runner
195
+ # ---------------------------------------------------------------------------
196
+
197
+ def _run_job(job_id: str, cmd: list[str], video_path: Path, cwd: str):
198
+ with JOBS_LOCK:
199
+ JOBS[job_id]["status"] = "running"
200
+
201
+ try:
202
+ proc = subprocess.Popen(
203
+ cmd,
204
+ stdout=subprocess.PIPE,
205
+ stderr=subprocess.STDOUT,
206
+ text=True,
207
+ cwd=cwd,
208
+ )
209
+ with JOBS_LOCK:
210
+ JOBS[job_id]["pid"] = proc.pid
211
+
212
+ for line in proc.stdout:
213
+ line = line.rstrip("\n")
214
+ with JOBS_LOCK:
215
+ JOBS[job_id]["log_lines"].append(line)
216
+
217
+ proc.wait()
218
+ success = proc.returncode == 0 and video_path.exists()
219
+ with JOBS_LOCK:
220
+ JOBS[job_id]["status"] = "done" if success else "error"
221
+ JOBS[job_id]["finished"] = time.time()
222
+ if success:
223
+ JOBS[job_id]["video_path"] = str(video_path)
224
+ except Exception as exc:
225
+ with JOBS_LOCK:
226
+ JOBS[job_id]["log_lines"].append(f"[webapp error] {exc}")
227
+ JOBS[job_id]["status"] = "error"
228
+ JOBS[job_id]["finished"] = time.time()
229
+
230
+ # ---------------------------------------------------------------------------
231
+ # Flask app
232
+ # ---------------------------------------------------------------------------
233
+
234
+ app = Flask(__name__)
235
+
236
+
237
+ @app.post("/generate")
238
+ def generate():
239
+ data = request.get_json(force=True)
240
+ prompt = data.get("prompt", "").strip()
241
+ if not prompt:
242
+ return jsonify(error="prompt required"), 400
243
+
244
+ model_id = data.get("model", MODELS[0]["id"])
245
+ if model_id not in MODEL_DIRS:
246
+ return jsonify(error=f"unknown model: {model_id}"), 400
247
+ model_dir = MODEL_DIRS[model_id]
248
+ if not Path(model_dir).exists():
249
+ return jsonify(error=f"model directory not found: {model_dir}"), 400
250
+
251
+ if not _model_ready(Path(model_dir)):
252
+ missing = _model_missing_files(Path(model_dir))
253
+ return jsonify(error=f"model not ready, missing: {missing}"), 400
254
+
255
+ height = int(data.get("height", 480))
256
+ width = int(data.get("width", 704))
257
+ num_frames = int(data.get("num_frames", 65))
258
+ frame_rate = float(data.get("frame_rate", 24.0))
259
+ seed = int(data.get("seed", 42))
260
+ stage1 = data.get("stage1_steps")
261
+ stage2 = data.get("stage2_steps")
262
+
263
+ job_id = uuid.uuid4().hex[:8]
264
+ video_path = RESULTS_DIR / f"gen_{job_id}.mp4"
265
+
266
+ if not GENERATE_SCRIPT.exists():
267
+ return jsonify(error=(
268
+ f"generate_ltx.py not found at {GENERATE_SCRIPT}. "
269
+ "Set the GENERATE_SCRIPT env var to its absolute path."
270
+ )), 500
271
+
272
+ # Determine the cwd for the subprocess. generate_ltx.py expects to be
273
+ # run from the RAM/RUN/ directory so its relative imports resolve.
274
+ script_cwd = str(GENERATE_SCRIPT.parent.parent.parent)
275
+
276
+ cmd = [
277
+ sys.executable,
278
+ str(GENERATE_SCRIPT),
279
+ "--model-dir", model_dir,
280
+ "--prompt", prompt,
281
+ "--output", str(video_path),
282
+ "--height", str(height),
283
+ "--width", str(width),
284
+ "--num-frames", str(num_frames),
285
+ "--frame-rate", str(frame_rate),
286
+ "--seed", str(seed),
287
+ ]
288
+ if stage1:
289
+ cmd += ["--stage1-steps", str(stage1)]
290
+ if stage2:
291
+ cmd += ["--stage2-steps", str(stage2)]
292
+
293
+ # Derive a friendly label for the model in job params
294
+ model_label = next((m["label"] for m in MODELS if m["id"] == model_id), model_id)
295
+
296
+ with JOBS_LOCK:
297
+ JOBS[job_id] = {
298
+ "status": "queued",
299
+ "log_lines": [],
300
+ "video_path": None,
301
+ "started": time.time(),
302
+ "finished": None,
303
+ "pid": None,
304
+ "params": {
305
+ "prompt": prompt,
306
+ "model": model_id,
307
+ "model_label": model_label,
308
+ "height": height,
309
+ "width": width,
310
+ "num_frames": num_frames,
311
+ "frame_rate": frame_rate,
312
+ "seed": seed,
313
+ },
314
+ }
315
+
316
+ t = threading.Thread(
317
+ target=_run_job, args=(job_id, cmd, video_path, script_cwd), daemon=True
318
+ )
319
+ t.start()
320
+ return jsonify(job_id=job_id)
321
+
322
+
323
+ @app.get("/stream/<job_id>")
324
+ def stream(job_id: str):
325
+ """SSE live log stream for a running job."""
326
+ if job_id not in JOBS:
327
+ return jsonify(error="not found"), 404
328
+
329
+ def generate_events():
330
+ sent = 0
331
+ while True:
332
+ with JOBS_LOCK:
333
+ lines = JOBS[job_id]["log_lines"]
334
+ status = JOBS[job_id]["status"]
335
+ new_lines = lines[sent:]
336
+ sent += len(new_lines)
337
+
338
+ for line in new_lines:
339
+ yield f"data: {json.dumps({'line': line})}\n\n"
340
+
341
+ if status in ("done", "error") and not new_lines:
342
+ with JOBS_LOCK:
343
+ final_status = JOBS[job_id]["status"]
344
+ video = JOBS[job_id]["video_path"]
345
+ yield f"data: {json.dumps({'done': True, 'status': final_status, 'video': video})}\n\n"
346
+ return
347
+
348
+ time.sleep(0.25)
349
+
350
+ return Response(
351
+ generate_events(),
352
+ mimetype="text/event-stream",
353
+ headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
354
+ )
355
+
356
+
357
+ @app.get("/video/<job_id>")
358
+ def video(job_id: str):
359
+ with JOBS_LOCK:
360
+ job = JOBS.get(job_id)
361
+ if not job or not job["video_path"]:
362
+ return jsonify(error="not found"), 404
363
+ p = Path(job["video_path"])
364
+ if not p.exists():
365
+ return jsonify(error="file missing"), 404
366
+ return send_file(str(p), mimetype="video/mp4", conditional=True)
367
+
368
+
369
+ @app.get("/models")
370
+ def list_models():
371
+ """Return the static model list (no polling needed β€” models are local)."""
372
+ return jsonify([
373
+ {
374
+ "id": m["id"],
375
+ "label": m["label"],
376
+ "dir": m["dir"],
377
+ "ready": m["ready"],
378
+ "missing": m["missing"],
379
+ }
380
+ for m in MODELS
381
+ ])
382
+
383
+
384
+ @app.get("/jobs")
385
+ def list_jobs():
386
+ with JOBS_LOCK:
387
+ out = []
388
+ for jid, j in reversed(list(JOBS.items())):
389
+ out.append({
390
+ "id": jid,
391
+ "status": j["status"],
392
+ "params": j["params"],
393
+ "started": j["started"],
394
+ "finished": j["finished"],
395
+ "has_video": bool(j["video_path"]),
396
+ })
397
+ return jsonify(out)
398
+
399
+
400
+ # ---------------------------------------------------------------------------
401
+ # HTML β€” single-file UI
402
+ # ---------------------------------------------------------------------------
403
+
404
+ def _build_html(models: list[dict]) -> str:
405
+ # Build the model selector: single static label if one model, <select> if two.
406
+ single_model = len(models) == 1
407
+
408
+ if single_model:
409
+ m = models[0]
410
+ model_block = f"""
411
+ <div>
412
+ <div class="section-title">Model</div>
413
+ <div id="model-display" style="
414
+ padding:10px 12px;
415
+ background:var(--bg);
416
+ border:1px solid var(--border);
417
+ border-radius:8px;
418
+ font-size:.9rem;
419
+ color:var(--text);
420
+ ">{m['label']}</div>
421
+ <input type="hidden" id="model" value="{m['id']}">
422
+ <div id="model-note" style="font-size:.72rem;color:var(--muted);margin-top:5px;min-height:1.2em"></div>
423
+ </div>"""
424
+ else:
425
+ options = "\n ".join(
426
+ f'<option value="{m["id"]}" {"disabled" if not m["ready"] else ""}>'
427
+ f'{m["label"]}{" (not ready)" if not m["ready"] else ""}'
428
+ f'</option>'
429
+ for m in models
430
+ )
431
+ # Default selection: first ready model
432
+ default_id = next((m["id"] for m in models if m["ready"]), models[0]["id"])
433
+ model_block = f"""
434
+ <div>
435
+ <div class="section-title">Model</div>
436
+ <select id="model" onchange="updateModelNote()">
437
+ {options}
438
+ </select>
439
+ <div id="model-note" style="font-size:.72rem;color:var(--muted);margin-top:5px;min-height:1.2em"></div>
440
+ </div>"""
441
+
442
+ # Startup warning if primary model is not ready
443
+ startup_warn = ""
444
+ if not models[0]["ready"]:
445
+ missing_list = ", ".join(models[0]["missing"])
446
+ startup_warn = f"""
447
+ <div style="
448
+ grid-column:1/-1;
449
+ background:#2a1a0a;
450
+ border-bottom:1px solid #5a3a0a;
451
+ padding:12px 28px;
452
+ font-size:.82rem;
453
+ color:#fbbf24;
454
+ ">
455
+ Model directory is missing required files: <code>{missing_list}</code>.
456
+ Run <code>reformat_ltx_for_pipeline.py</code> first.
457
+ </div>"""
458
+
459
+ # Models JSON for JS
460
+ models_json = json.dumps([{"id": m["id"], "ready": m["ready"], "label": m["label"]} for m in models])
461
+
462
+ return f"""<!doctype html>
463
+ <html lang="en">
464
+ <head>
465
+ <meta charset="utf-8">
466
+ <meta name="viewport" content="width=device-width, initial-scale=1">
467
+ <title>{models[0]['label']} β€” Video Generator</title>
468
+ <style>
469
+ *, *::before, *::after {{ box-sizing: border-box; margin: 0; padding: 0; }}
470
+
471
+ :root {{
472
+ --bg: #0f0f13;
473
+ --surface: #1a1a22;
474
+ --border: #2e2e3a;
475
+ --accent: #7c6af7;
476
+ --accent2: #a78bfa;
477
+ --text: #e2e2f0;
478
+ --muted: #6b6b82;
479
+ --green: #34d399;
480
+ --red: #f87171;
481
+ --yellow: #fbbf24;
482
+ }}
483
+
484
+ body {{
485
+ background: var(--bg);
486
+ color: var(--text);
487
+ font-family: system-ui, -apple-system, sans-serif;
488
+ min-height: 100vh;
489
+ display: grid;
490
+ grid-template-columns: 380px 1fr;
491
+ grid-template-rows: auto auto 1fr;
492
+ gap: 0;
493
+ }}
494
+
495
+ header {{
496
+ grid-column: 1 / -1;
497
+ padding: 18px 28px;
498
+ border-bottom: 1px solid var(--border);
499
+ display: flex;
500
+ align-items: center;
501
+ gap: 12px;
502
+ }}
503
+ header h1 {{ font-size: 1.1rem; font-weight: 600; letter-spacing: .02em; }}
504
+ header .badge {{
505
+ font-size: .7rem; background: var(--accent); color: #fff;
506
+ padding: 2px 8px; border-radius: 99px; font-weight: 600;
507
+ }}
508
+
509
+ .warn-banner {{ grid-column: 1 / -1; }}
510
+
511
+ .sidebar {{
512
+ grid-column: 1;
513
+ border-right: 1px solid var(--border);
514
+ padding: 24px 20px;
515
+ display: flex;
516
+ flex-direction: column;
517
+ gap: 18px;
518
+ overflow-y: auto;
519
+ }}
520
+
521
+ .main {{
522
+ grid-column: 2;
523
+ padding: 24px 28px;
524
+ display: flex;
525
+ flex-direction: column;
526
+ gap: 20px;
527
+ overflow-y: auto;
528
+ }}
529
+
530
+ label {{ font-size: .8rem; color: var(--muted); display: block; margin-bottom: 5px; }}
531
+
532
+ textarea, input, select {{
533
+ width: 100%;
534
+ background: var(--bg);
535
+ border: 1px solid var(--border);
536
+ border-radius: 8px;
537
+ color: var(--text);
538
+ padding: 10px 12px;
539
+ font-size: .9rem;
540
+ font-family: inherit;
541
+ outline: none;
542
+ transition: border-color .15s;
543
+ }}
544
+ textarea:focus, input:focus, select:focus {{ border-color: var(--accent); }}
545
+ textarea {{ resize: vertical; min-height: 90px; }}
546
+
547
+ .row {{ display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }}
548
+
549
+ button {{
550
+ width: 100%;
551
+ padding: 12px;
552
+ border: none;
553
+ border-radius: 8px;
554
+ background: var(--accent);
555
+ color: #fff;
556
+ font-size: .95rem;
557
+ font-weight: 600;
558
+ cursor: pointer;
559
+ transition: opacity .15s, background .15s;
560
+ }}
561
+ button:hover {{ opacity: .9; }}
562
+ button:disabled {{ background: var(--border); color: var(--muted); cursor: not-allowed; opacity: 1; }}
563
+
564
+ .section-title {{
565
+ font-size: .7rem;
566
+ font-weight: 700;
567
+ letter-spacing: .1em;
568
+ text-transform: uppercase;
569
+ color: var(--muted);
570
+ margin-bottom: -8px;
571
+ }}
572
+
573
+ .presets {{ display: flex; gap: 6px; flex-wrap: wrap; }}
574
+ .preset {{
575
+ font-size: .78rem; padding: 4px 10px; border-radius: 6px;
576
+ border: 1px solid var(--border); background: var(--surface);
577
+ cursor: pointer; color: var(--text); transition: border-color .15s;
578
+ white-space: nowrap;
579
+ }}
580
+ .preset:hover, .preset.active {{ border-color: var(--accent); color: var(--accent2); }}
581
+
582
+ .status-pill {{
583
+ display: inline-flex; align-items: center; gap: 6px;
584
+ font-size: .8rem; padding: 3px 10px; border-radius: 99px;
585
+ font-weight: 600;
586
+ }}
587
+ .status-pill.queued {{ background: #2a2a1a; color: var(--yellow); }}
588
+ .status-pill.running {{ background: #1a1a2e; color: var(--accent2); }}
589
+ .status-pill.done {{ background: #0d2a1e; color: var(--green); }}
590
+ .status-pill.error {{ background: #2a0f0f; color: var(--red); }}
591
+ .dot {{ width: 7px; height: 7px; border-radius: 50%; background: currentColor; }}
592
+ .dot.pulse {{ animation: pulse 1s infinite; }}
593
+ @keyframes pulse {{ 0%,100%{{opacity:1}} 50%{{opacity:.3}} }}
594
+
595
+ .log-wrap {{
596
+ background: var(--surface);
597
+ border: 1px solid var(--border);
598
+ border-radius: 10px;
599
+ overflow: hidden;
600
+ flex: 1;
601
+ min-height: 200px;
602
+ display: flex;
603
+ flex-direction: column;
604
+ }}
605
+ .log-header {{
606
+ padding: 10px 14px;
607
+ border-bottom: 1px solid var(--border);
608
+ font-size: .78rem;
609
+ color: var(--muted);
610
+ display: flex;
611
+ align-items: center;
612
+ gap: 8px;
613
+ }}
614
+ .log-body {{
615
+ flex: 1;
616
+ overflow-y: auto;
617
+ padding: 12px 14px;
618
+ font-family: 'SF Mono', 'Fira Mono', monospace;
619
+ font-size: .78rem;
620
+ line-height: 1.6;
621
+ color: #b0b0c8;
622
+ white-space: pre-wrap;
623
+ word-break: break-all;
624
+ max-height: 300px;
625
+ }}
626
+ .log-body:empty::before {{ content: 'Waiting for output\2026'; color: var(--muted); }}
627
+
628
+ .video-wrap {{
629
+ background: var(--surface);
630
+ border: 1px solid var(--border);
631
+ border-radius: 10px;
632
+ overflow: hidden;
633
+ }}
634
+ .video-wrap video {{
635
+ width: 100%;
636
+ display: block;
637
+ background: #000;
638
+ max-height: 480px;
639
+ }}
640
+ .video-placeholder {{
641
+ height: 200px;
642
+ display: flex;
643
+ align-items: center;
644
+ justify-content: center;
645
+ color: var(--muted);
646
+ font-size: .85rem;
647
+ }}
648
+
649
+ .history-item {{
650
+ background: var(--surface);
651
+ border: 1px solid var(--border);
652
+ border-radius: 8px;
653
+ padding: 10px 12px;
654
+ font-size: .82rem;
655
+ display: flex;
656
+ flex-direction: column;
657
+ gap: 4px;
658
+ cursor: pointer;
659
+ transition: border-color .15s;
660
+ margin-bottom: 8px;
661
+ }}
662
+ .history-item:hover {{ border-color: var(--accent); }}
663
+ .history-item .prompt {{ color: var(--text); font-weight: 500; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }}
664
+ .history-item .meta {{ color: var(--muted); font-size: .75rem; }}
665
+
666
+ .empty-state {{ color: var(--muted); font-size: .85rem; text-align: center; padding: 20px 0; }}
667
+
668
+ code {{
669
+ font-family: 'SF Mono', 'Fira Mono', monospace;
670
+ font-size: .85em;
671
+ background: #1e1e2a;
672
+ padding: 1px 5px;
673
+ border-radius: 4px;
674
+ }}
675
+ </style>
676
+ </head>
677
+ <body>
678
+
679
+ <header>
680
+ <h1>{models[0]['label']}</h1>
681
+ <span class="badge">RAM Mixed-Precision</span>
682
+ </header>
683
+
684
+ {startup_warn}
685
+
686
+ <aside class="sidebar">
687
+ {model_block}
688
+
689
+ <div>
690
+ <label for="prompt">Prompt</label>
691
+ <textarea id="prompt" rows="4" placeholder="Describe the video you want to generate…">A serene mountain lake at sunrise, mist over calm water, pine trees reflected</textarea>
692
+ </div>
693
+
694
+ <div>
695
+ <div class="section-title" style="margin-bottom:10px">Resolution presets</div>
696
+ <div class="presets">
697
+ <div class="preset" data-h="256" data-w="256" data-f="33" title="256Γ—256, 33 frames">tiny</div>
698
+ <div class="preset active" data-h="480" data-w="704" data-f="65" title="480Γ—704, 65 frames">480pΒ·65f</div>
699
+ <div class="preset" data-h="480" data-w="704" data-f="97" title="480Γ—704, 97 frames">480pΒ·97f</div>
700
+ <div class="preset" data-h="720" data-w="1280" data-f="65" title="720Γ—1280, 65 frames">720pΒ·65f</div>
701
+ </div>
702
+ </div>
703
+
704
+ <div class="row">
705
+ <div>
706
+ <label for="height">Height</label>
707
+ <input type="number" id="height" value="480" step="32" min="128">
708
+ </div>
709
+ <div>
710
+ <label for="width">Width</label>
711
+ <input type="number" id="width" value="704" step="32" min="128">
712
+ </div>
713
+ </div>
714
+
715
+ <input type="hidden" id="num_frames" value="65">
716
+
717
+ <div class="row">
718
+ <div>
719
+ <div style="display:flex;justify-content:space-between;align-items:baseline;margin-bottom:5px">
720
+ <label for="duration_s" style="margin:0">Duration</label>
721
+ <span id="frames-display" style="font-size:.75rem;color:var(--muted)">=&thinsp;65 frames</span>
722
+ </div>
723
+ <div style="display:flex;gap:6px;align-items:center">
724
+ <input type="number" id="duration_s" value="2.7" step="0.5" min="0.5" oninput="updateFrames()">
725
+ <span style="color:var(--muted);font-size:.85rem;white-space:nowrap;padding-right:4px">s</span>
726
+ </div>
727
+ </div>
728
+ <div>
729
+ <label for="frame_rate">FPS</label>
730
+ <input type="number" id="frame_rate" value="24" step="1" min="8" max="60" oninput="updateFrames()">
731
+ </div>
732
+ </div>
733
+
734
+ <div class="row">
735
+ <div>
736
+ <label for="seed">Seed</label>
737
+ <input type="number" id="seed" value="42">
738
+ </div>
739
+ <div style="display:flex;flex-direction:column;justify-content:flex-end">
740
+ <button type="button" onclick="randomSeed()" style="padding:10px;font-size:.8rem;background:var(--surface);color:var(--text);border:1px solid var(--border)">\U0001f3b2 Random</button>
741
+ </div>
742
+ </div>
743
+
744
+ <button id="btn-generate" onclick="startGeneration()">Generate</button>
745
+ </aside>
746
+
747
+ <main class="main">
748
+ <div id="status-row" style="display:none;align-items:center;gap:12px">
749
+ <span id="status-pill" class="status-pill queued"><span class="dot"></span> queued</span>
750
+ <span id="status-info" style="font-size:.82rem;color:var(--muted)"></span>
751
+ </div>
752
+
753
+ <div id="video-section">
754
+ <div class="video-wrap">
755
+ <div class="video-placeholder" id="video-placeholder">Video will appear here after generation</div>
756
+ <video id="video-el" controls style="display:none" playsinline></video>
757
+ </div>
758
+ </div>
759
+
760
+ <div class="log-wrap">
761
+ <div class="log-header">
762
+ <span>Output log</span>
763
+ <span id="log-status" style="margin-left:auto"></span>
764
+ </div>
765
+ <div class="log-body" id="log-body"></div>
766
+ </div>
767
+
768
+ <div>
769
+ <div class="section-title" style="margin-bottom:12px">Recent generations</div>
770
+ <div id="history"></div>
771
+ </div>
772
+ </main>
773
+
774
+ <script>
775
+ // Static model list injected server-side β€” no polling needed.
776
+ const MODELS = {models_json};
777
+ const singleModel = MODELS.length === 1;
778
+
779
+ let currentJobId = null;
780
+ let currentESS = null;
781
+
782
+ // ── Model note ────────────────────────────────────────────────────────────────
783
+
784
+ function updateModelNote() {{
785
+ if (singleModel) return;
786
+ const sel = document.getElementById('model');
787
+ const m = MODELS.find(x => x.id === sel.value);
788
+ const noteEl = document.getElementById('model-note');
789
+ noteEl.textContent = (m && !m.ready) ? '⚠️ Model not ready β€” missing required files.' : '';
790
+ }}
791
+
792
+ // Run once on load if using <select>
793
+ if (!singleModel) updateModelNote();
794
+
795
+ // ── Helpers ───────────────────────────────────────────────────────────────────
796
+
797
+ function randomSeed() {{
798
+ document.getElementById('seed').value = Math.floor(Math.random() * 2**31);
799
+ }}
800
+
801
+ function updateFrames() {{
802
+ const secs = parseFloat(document.getElementById('duration_s').value) || 2.7;
803
+ const fps = parseFloat(document.getElementById('frame_rate').value) || 24;
804
+ // LTX requires 32k+1 frames (k β‰₯ 1), minimum 33
805
+ const k = Math.max(1, Math.round((secs * fps - 1) / 32));
806
+ const frames = k * 32 + 1;
807
+ document.getElementById('num_frames').value = frames;
808
+ document.getElementById('frames-display').textContent = `= ${{frames}} frames`;
809
+ }}
810
+
811
+ function syncDurationFromFrames(frames) {{
812
+ const fps = parseFloat(document.getElementById('frame_rate').value) || 24;
813
+ document.getElementById('duration_s').value = (frames / fps).toFixed(1);
814
+ document.getElementById('num_frames').value = frames;
815
+ document.getElementById('frames-display').textContent = `= ${{frames}} frames`;
816
+ }}
817
+
818
+ document.querySelectorAll('.preset').forEach(el => {{
819
+ el.addEventListener('click', () => {{
820
+ document.querySelectorAll('.preset').forEach(p => p.classList.remove('active'));
821
+ el.classList.add('active');
822
+ document.getElementById('height').value = el.dataset.h;
823
+ document.getElementById('width').value = el.dataset.w;
824
+ syncDurationFromFrames(parseInt(el.dataset.f));
825
+ }});
826
+ }});
827
+
828
+ // ── Generation ────────────────────────────────────────────────────────────────
829
+
830
+ async function startGeneration() {{
831
+ const prompt = document.getElementById('prompt').value.trim();
832
+ if (!prompt) {{ alert('Enter a prompt first.'); return; }}
833
+
834
+ const btn = document.getElementById('btn-generate');
835
+ btn.disabled = true;
836
+
837
+ document.getElementById('log-body').textContent = '';
838
+ document.getElementById('video-el').style.display = 'none';
839
+ document.getElementById('video-placeholder').style.display = 'flex';
840
+ document.getElementById('status-row').style.display = 'flex';
841
+ setStatus('queued');
842
+
843
+ if (currentESS) {{ currentESS.close(); currentESS = null; }}
844
+
845
+ const body = {{
846
+ prompt,
847
+ model: document.getElementById('model').value,
848
+ height: parseInt(document.getElementById('height').value),
849
+ width: parseInt(document.getElementById('width').value),
850
+ num_frames: parseInt(document.getElementById('num_frames').value),
851
+ frame_rate: parseFloat(document.getElementById('frame_rate').value),
852
+ seed: parseInt(document.getElementById('seed').value),
853
+ }};
854
+
855
+ const res = await fetch('/generate', {{
856
+ method: 'POST',
857
+ headers: {{'Content-Type': 'application/json'}},
858
+ body: JSON.stringify(body),
859
+ }});
860
+ if (!res.ok) {{
861
+ const err = await res.json();
862
+ alert('Error: ' + (err.error || res.statusText));
863
+ btn.disabled = false;
864
+ return;
865
+ }}
866
+ const {{ job_id }} = await res.json();
867
+ currentJobId = job_id;
868
+
869
+ document.getElementById('status-info').textContent = `job ${{job_id}}`;
870
+ setStatus('running');
871
+
872
+ const sse = new EventSource(`/stream/${{job_id}}`);
873
+ currentESS = sse;
874
+ const logEl = document.getElementById('log-body');
875
+
876
+ sse.onmessage = (e) => {{
877
+ const data = JSON.parse(e.data);
878
+ if (data.line !== undefined) {{
879
+ logEl.textContent += data.line + '\\n';
880
+ logEl.scrollTop = logEl.scrollHeight;
881
+ }}
882
+ if (data.done) {{
883
+ sse.close();
884
+ currentESS = null;
885
+ btn.disabled = false;
886
+ setStatus(data.status);
887
+ if (data.status === 'done' && data.video) showVideo(job_id);
888
+ refreshHistory();
889
+ }}
890
+ }};
891
+ sse.onerror = () => {{
892
+ sse.close();
893
+ currentESS = null;
894
+ btn.disabled = false;
895
+ }};
896
+ }}
897
+
898
+ function setStatus(s) {{
899
+ const pill = document.getElementById('status-pill');
900
+ pill.className = `status-pill ${{s}}`;
901
+ const dot = pill.querySelector('.dot');
902
+ dot.className = 'dot' + (s === 'running' ? ' pulse' : '');
903
+ dot.nextSibling.textContent = ' ' + s;
904
+ }}
905
+
906
+ function showVideo(job_id) {{
907
+ const el = document.getElementById('video-el');
908
+ el.src = `/video/${{job_id}}`;
909
+ el.style.display = 'block';
910
+ document.getElementById('video-placeholder').style.display = 'none';
911
+ el.load();
912
+ el.play().catch(() => {{}});
913
+ }}
914
+
915
+ // ── History ───────────────────────────────────────────────────────────────────
916
+
917
+ async function refreshHistory() {{
918
+ const res = await fetch('/jobs');
919
+ if (!res.ok) return;
920
+ const jobs = await res.json();
921
+ const el = document.getElementById('history');
922
+ if (!jobs.length) {{
923
+ el.innerHTML = '<div class="empty-state">No generations yet</div>';
924
+ return;
925
+ }}
926
+ el.innerHTML = jobs.slice(0, 10).map(j => {{
927
+ const ago = Math.round((Date.now() / 1000 - j.started) / 60);
928
+ const duration = j.finished ? `${{Math.round(j.finished - j.started)}}s` : '…';
929
+ const modelLbl = j.params.model_label || j.params.model;
930
+ return `<div class="history-item" onclick="loadJob('${{j.id}}')">
931
+ <div class="prompt">${{escHtml(j.params.prompt)}}</div>
932
+ <div class="meta">${{escHtml(modelLbl)}} Β· ${{j.params.height}}Γ—${{j.params.width}} Β· ${{j.params.num_frames}}f Β· seed ${{j.params.seed}} Β· ${{duration}} Β· ${{ago}}m ago</div>
933
+ </div>`;
934
+ }}).join('');
935
+ }}
936
+
937
+ async function loadJob(job_id) {{
938
+ const res = await fetch('/jobs');
939
+ const jobs = await res.json();
940
+ const j = jobs.find(x => x.id === job_id);
941
+ if (!j) return;
942
+
943
+ if (!singleModel) document.getElementById('model').value = j.params.model;
944
+ document.getElementById('prompt').value = j.params.prompt;
945
+ document.getElementById('height').value = j.params.height;
946
+ document.getElementById('width').value = j.params.width;
947
+ document.getElementById('frame_rate').value = j.params.frame_rate;
948
+ document.getElementById('seed').value = j.params.seed;
949
+ syncDurationFromFrames(j.params.num_frames);
950
+
951
+ if (j.has_video) {{
952
+ document.getElementById('video-el').style.display = 'none';
953
+ document.getElementById('video-placeholder').style.display = 'flex';
954
+ showVideo(job_id);
955
+ document.getElementById('status-row').style.display = 'flex';
956
+ setStatus(j.status);
957
+ document.getElementById('status-info').textContent = `job ${{job_id}}`;
958
+ }}
959
+ }}
960
+
961
+ function escHtml(s) {{
962
+ return s.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
963
+ }}
964
+
965
+ // ── Init ──────────────────────────────────────────────────────────────────────
966
+ refreshHistory();
967
+ </script>
968
+ </body>
969
+ </html>"""
970
+
971
+
972
+ @app.get("/")
973
+ def index():
974
+ return _build_html(MODELS)
975
+
976
+
977
+ # ---------------------------------------------------------------------------
978
+ # Entry point
979
+ # ---------------------------------------------------------------------------
980
+
981
+ if __name__ == "__main__":
982
+ import webbrowser
983
+ import threading as _threading
984
+
985
+ port = _args.port
986
+
987
+ # Print startup summary
988
+ print(f"\nLTX-2.3 Standalone Web UI")
989
+ print(f" Primary model : {PRIMARY_NAME}")
990
+ print(f" Directory : {PRIMARY_DIR}")
991
+ print(f" Ready : {_model_ready(PRIMARY_DIR)}")
992
+ if COMPARE_DIR:
993
+ print(f" Compare model : {COMPARE_NAME}")
994
+ print(f" Compare dir : {COMPARE_DIR}")
995
+ print(f" Compare ready : {_model_ready(COMPARE_DIR)}")
996
+ print(f" Generate script: {GENERATE_SCRIPT} ({'found' if GENERATE_SCRIPT.exists() else 'NOT FOUND β€” set GENERATE_SCRIPT env var'})")
997
+ print(f" Videos saved to: {RESULTS_DIR}")
998
+ print(f"\n http://localhost:{port}\n")
999
+
1000
+ def _open():
1001
+ time.sleep(1.0)
1002
+ webbrowser.open(f"http://localhost:{port}")
1003
+
1004
+ _threading.Thread(target=_open, daemon=True).start()
1005
+ app.run(host="0.0.0.0", port=port, debug=False, threaded=True)