Files changed (1) hide show
  1. app.py +201 -1104
app.py CHANGED
@@ -1,1138 +1,235 @@
1
- # app.py
2
- """LTX 2.3 All-in-One — Gradio entry point."""
3
-
4
- from __future__ import annotations
5
-
6
- import os
7
- import pathlib
8
- import random
9
- import sys
10
- import time
11
- from typing import Any
12
-
13
  import gradio as gr
 
14
 
15
- import backend as backend_module
16
- import modes
17
- import ui
18
- import workflow as wf_module
19
-
20
- # ---------------------------------------------------------------------------
21
- # Bootstrap — runs once on cold start.
22
- # ---------------------------------------------------------------------------
23
-
24
-
25
- def _on_spaces() -> bool:
26
- return bool(os.environ.get("SPACES_ZERO_GPU"))
27
-
28
-
29
- COMFYUI_REPO = "https://github.com/comfyanonymous/ComfyUI.git"
30
- COMFYUI_COMMIT = os.environ.get(
31
- "LTX23_AIO_COMFYUI_COMMIT",
32
- "eb0686bbb60c83e44c3a3e4f7defd0f589cfef10",
33
- )
34
-
35
- CUSTOM_NODES_PINNED: list[tuple[str, str]] = [
36
- ("https://github.com/Lightricks/ComfyUI-LTXVideo.git", "2acf7af8991f33b5cc06ec26753cb6e88e057d04"),
37
- ("https://github.com/kijai/ComfyUI-KJNodes.git", "01d9fa9c983273532cacdf9532c74a93c7dc86d2"),
38
- ("https://github.com/rgthree/rgthree-comfy.git", "683836c46e898668936c433502504cc0627482c5"),
39
- ("https://github.com/Kosinkadink/ComfyUI-VideoHelperSuite.git", "2984ec4c4b93292421888f38db74a5e8802a8ff8"),
40
- ("https://github.com/pythongosssss/ComfyUI-Custom-Scripts.git", "609f3afaa74b2f88ef9ce8d939626065e3247469"),
41
- ("https://github.com/city96/ComfyUI-GGUF.git", "6ea2651e7df66d7585f6ffee804b20e92fb38b8a"),
42
- ("https://github.com/Fannovel16/comfyui_controlnet_aux.git", "e8b689a513c3e6b63edc44066560ca5919c0576e"),
43
- ("https://github.com/evanspearman/ComfyMath.git", "c01177221c31b8e5fbc062778fc8254aeb541638"),
44
- ("https://github.com/Smirnov75/ComfyUI-mxToolkit.git", "7f7a0e584f12078a1c589645d866ae96bad0cc35"),
45
- ("https://github.com/DoctorDiffusion/ComfyUI-MediaMixer.git", "2bae7b5ea8fc52d8a4d668d62fed76265f4eec2c"),
46
- ]
47
-
48
-
49
- def _git_clone(url: str, dst: pathlib.Path, ref: str) -> None:
50
- """Clone *url* at *ref* into *dst*. *ref* may be a branch, tag, or SHA.
51
-
52
- `git clone --branch` only accepts branch/tag names, so we use init+fetch
53
- which works for any object GitHub allows fetching (default: reachable
54
- commits in public repos).
55
- """
56
- import subprocess
57
-
58
- dst = pathlib.Path(dst)
59
- dst.mkdir(parents=True, exist_ok=True)
60
- subprocess.check_call(["git", "-C", str(dst), "init", "-q"])
61
- subprocess.check_call(["git", "-C", str(dst), "remote", "add", "origin", url])
62
- subprocess.check_call(["git", "-C", str(dst), "fetch", "--depth", "1", "origin", ref])
63
- subprocess.check_call(["git", "-C", str(dst), "checkout", "-q", "FETCH_HEAD"])
64
-
65
-
66
- def _mirror_preload_hf_cache() -> None:
67
- """Mirror the build-populated HF cache into a writable runtime tree.
68
-
69
- HF Spaces' build pipeline runs `preload_from_hub` as a different user
70
- than the runtime container, so the populated `~/.cache/huggingface/`
71
- is read-only for us (uid 1000). Any subsequent `hf_hub_download` call
72
- that needs to write a NEW file (lazy-loaded LoRAs, GGUF, etc.) fails
73
- with "Permission denied" because the parent dir isn't writable.
74
-
75
- Fix: build a parallel tree at `~/hf-cache-rw/` that we own, with:
76
- - dirs: created fresh via mkdir
77
- - blob files (`blobs/<sha>`): hardlinked (shared inode, instant)
78
- - relative snapshot symlinks: preserved as symlinks
79
- - `refs/<branch>` files: byte-copied (HF lib overwrites these)
80
- - everything else: byte-copied (safest default)
81
- Then set HF_HOME / HF_HUB_CACHE so HF lib reads/writes through the
82
- mirror. Reads are zero-copy via hardlink/symlink; new downloads land
83
- in dirs we created.
84
- """
85
- import shutil
86
-
87
- src_root = pathlib.Path.home() / ".cache" / "huggingface"
88
- dst_root = pathlib.Path.home() / "hf-cache-rw"
89
- dst_root.mkdir(parents=True, exist_ok=True)
90
- os.environ["HF_HOME"] = str(dst_root)
91
- os.environ["HF_HUB_CACHE"] = str(dst_root / "hub")
92
-
93
- if not src_root.exists():
94
- return
95
-
96
- counts = {"dirs": 0, "hardlinks": 0, "symlinks": 0, "copies": 0, "errors": 0}
97
-
98
- def _treat_as_copy(rel_path: pathlib.PurePath) -> bool:
99
- # Anything under a refs/ dir, anywhere in the tree.
100
- return any(part == "refs" for part in rel_path.parts)
101
-
102
- def _walk(s: pathlib.Path, d: pathlib.Path) -> None:
103
- try:
104
- d.mkdir(parents=True, exist_ok=True)
105
- counts["dirs"] += 1
106
- except OSError as exc:
107
- print(f"[bootstrap] mirror mkdir fail {d}: {exc}", flush=True)
108
- counts["errors"] += 1
109
- return
110
-
111
- for entry in s.iterdir():
112
- de = d / entry.name
113
- try:
114
- if entry.is_symlink():
115
- if de.exists() or de.is_symlink():
116
- continue
117
- target = os.readlink(str(entry))
118
- de.symlink_to(target)
119
- counts["symlinks"] += 1
120
- elif entry.is_dir():
121
- _walk(entry, de)
122
- elif entry.is_file():
123
- if de.exists():
124
- continue
125
- rel = de.relative_to(dst_root)
126
- if _treat_as_copy(rel):
127
- shutil.copy2(entry, de)
128
- counts["copies"] += 1
129
- else:
130
- try:
131
- os.link(str(entry), str(de))
132
- counts["hardlinks"] += 1
133
- except OSError:
134
- # Cross-device or other — fall back to symlink.
135
- de.symlink_to(entry)
136
- counts["symlinks"] += 1
137
- except OSError as exc:
138
- print(f"[bootstrap] mirror skip {entry}: {exc}", flush=True)
139
- counts["errors"] += 1
140
-
141
- _walk(src_root, dst_root)
142
- print(
143
- f"[bootstrap] hf cache mirrored to {dst_root}: "
144
- f"{counts['dirs']} dirs, {counts['hardlinks']} hardlinks, "
145
- f"{counts['symlinks']} symlinks, {counts['copies']} copies, "
146
- f"{counts['errors']} errors",
147
- flush=True,
148
- )
149
-
150
-
151
- def _bootstrap() -> None:
152
- on_spaces = _on_spaces()
153
- # /data requires the paid persistent-storage add-on (separate from Pro).
154
- # Without it, /data is unwritable. $HOME is writable and — because ZeroGPU
155
- # containers freeze on sleep rather than tear down — the clone persists
156
- # across calls within a single deploy.
157
- comfy_dir = (pathlib.Path.home() / "comfyui") if on_spaces else pathlib.Path("comfyui")
158
-
159
- if on_spaces and not comfy_dir.exists():
160
- print(f"[bootstrap] cold start on Spaces; cloning ComfyUI to {comfy_dir}", flush=True)
161
- comfy_dir.parent.mkdir(parents=True, exist_ok=True)
162
- _git_clone(COMFYUI_REPO, comfy_dir, ref=COMFYUI_COMMIT)
163
- for node_url, node_ref in CUSTOM_NODES_PINNED:
164
- name = node_url.rstrip(".git").rsplit("/", 1)[-1]
165
- _git_clone(node_url, comfy_dir / "custom_nodes" / name, ref=node_ref)
166
- import subprocess
167
-
168
- # ComfyUI core requirements + each custom node's requirements
169
- for req_path in [
170
- comfy_dir / "requirements.txt",
171
- *(cn / "requirements.txt" for cn in (comfy_dir / "custom_nodes").iterdir()),
172
- ]:
173
- if req_path.exists():
174
- print(f"[bootstrap] pip install -r {req_path}", flush=True)
175
- subprocess.check_call(
176
- [sys.executable, "-m", "pip", "install", "--quiet", "-r", str(req_path)]
177
- )
178
-
179
- if str(comfy_dir) not in sys.path:
180
- sys.path.insert(0, str(comfy_dir))
181
- os.environ.setdefault("COMFY_MODELS_DIR", str(comfy_dir / "models"))
182
-
183
- # Mirror the build-time HF cache (populated by preload_from_hub, owned by
184
- # build user → read-only for runtime user 1000) into a writable parallel
185
- # tree under $HOME, then point HF_HUB_CACHE / HF_HOME at it. After this:
186
- # - preloaded blobs are accessible via hardlink (no data copy, instant reads)
187
- # - relative snapshot symlinks resolve within the mirror
188
- # - refs/* are byte-copies so HF lib can overwrite when commits advance
189
- # - new lazy-downloaded files write to dirs we own → no permission errors
190
- if on_spaces:
191
- _mirror_preload_hf_cache()
192
-
193
- # Stage placeholder input files so the workflow's hard-referenced loaders
194
- # (LoadImage/VHS_Load*) don't error at runtime even when the active mode
195
- # doesn't actually use the file. Real user uploads are placed alongside via
196
- # `_stage_to_comfy_input` later.
197
- seed_dir = pathlib.Path(__file__).parent / "assets" / "seed_inputs"
198
- inputs_dir = comfy_dir / "input"
199
- inputs_dir.mkdir(parents=True, exist_ok=True)
200
- if seed_dir.exists():
201
- import shutil
202
-
203
- for src in seed_dir.iterdir():
204
- if not src.is_file():
205
- continue
206
- dst = inputs_dir / src.name
207
- if not dst.exists():
208
- try:
209
- shutil.copy2(src, dst)
210
- except OSError as exc:
211
- print(f"[bootstrap] could not seed {src.name}: {exc}", flush=True)
212
-
213
-
214
- _bootstrap()
215
-
216
-
217
- # ---------------------------------------------------------------------------
218
- # Styling: hide the default top tab strip (drawer nav drives selection),
219
- # add status-card styling, plus single responsive breakpoint at 1023 px
220
- # (drawer slides over body) / 1024 px+ (drawer pinned).
221
- # ---------------------------------------------------------------------------
222
-
223
- _CUSTOM_CSS = """
224
- /* Hide Gradio's top tab strip — sidebar drives selection. */
225
- .aio-tabs > .tab-nav,
226
- .aio-tabs > div:first-child[role="tablist"],
227
- .aio-tabs > div:first-child:has([role="tab"]) {
228
- position: absolute !important;
229
- left: -99999px !important;
230
- top: -99999px !important;
231
- height: 0 !important;
232
- overflow: hidden !important;
233
- visibility: visible !important;
234
- pointer-events: auto !important;
235
  }
236
 
237
- /* === Header === */
238
- .aio-header {
239
  display: flex;
240
  align-items: center;
241
- gap: 12px;
242
- padding: 11px 18px;
243
- border-bottom: 1px solid #262C35;
244
- background: #12161B;
245
- position: relative;
246
- /* HF injects #huggingface-space-header at fixed z-index 20 (top-right
247
- like/share widget). Stay below it by default so we don't cover it. */
248
- z-index: 15;
 
 
 
 
 
 
 
 
 
 
249
  }
250
- /* When drawer is open, lift header above scrim (z-45) and drawer (z-50) so
251
- the hamburger flips to × and remains clickable as a close affordance.
252
- Toggled in lockstep with .aio-shell.drawer-open via the inline JS below. */
253
- .aio-header.drawer-elevated {
254
- z-index: 60;
255
  }
256
- .aio-ham-label {
257
- display: none;
258
- width: 32px; height: 32px;
259
- border: 1px solid #262C35;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
260
  border-radius: 5px;
261
- color: #7C8693;
262
  cursor: pointer;
263
- align-items: center; justify-content: center;
264
- font-size: 18px; font-weight: 300;
265
- user-select: none;
266
  }
267
- .aio-ham-label:hover { color: #E0A458; border-color: #E0A458; }
268
- .aio-title {
269
- font-size: 15px; font-weight: 600; letter-spacing: -0.01em;
270
- color: #E6E8EB;
271
  }
272
- .aio-title .accent { color: #E0A458; }
273
- .aio-mode-tag {
274
- margin-left: auto;
275
- padding: 4px 9px;
276
- font-family: 'IBM Plex Mono', ui-monospace, monospace;
277
- font-size: 11px; font-weight: 500; letter-spacing: 0.04em;
278
- color: #E0A458;
279
- border: 1px solid #E0A458;
280
- border-radius: 4px;
281
  }
282
 
283
- .aio-tipbar {
284
- margin: 0 0 6px 0;
285
- padding: 6px 14px;
286
- font-family: 'IBM Plex Sans', system-ui, sans-serif;
287
- font-size: 12px;
288
- color: #B5BCC6;
289
- background: #1A1F26;
290
- border-bottom: 1px solid #262C35;
291
- text-align: center;
292
  }
293
- .aio-tipbar strong { color: #E6E8EB; font-weight: 500; }
294
- .aio-tipbar .aio-heart { color: #E55B6E; }
295
-
296
- .aio-mode-warning {
297
- margin: 4px 0 10px 0 !important;
298
- padding: 10px 14px !important;
299
- font-family: 'IBM Plex Sans', system-ui, sans-serif !important;
300
- font-size: 12px !important;
301
- line-height: 1.55 !important;
302
- color: #D4C18B !important;
303
- background: rgba(224, 164, 88, 0.08) !important;
304
- border-left: 3px solid #E0A458 !important;
305
- border-radius: 4px !important;
306
  }
307
- .aio-mode-warning strong { color: #E0A458 !important; font-weight: 500 !important; }
308
 
309
- .aio-hf-tip {
310
- margin: 12px 0 8px 0 !important;
311
- padding: 9px 14px !important;
312
- font-family: 'IBM Plex Sans', system-ui, sans-serif !important;
313
- font-size: 11.5px !important;
314
- line-height: 1.5 !important;
315
- color: #9CA8B5 !important;
316
- background: rgba(124, 134, 147, 0.06) !important;
317
- border-left: 3px solid #5C6671 !important;
318
- border-radius: 4px !important;
319
  }
320
- .aio-hf-tip strong { color: #C8D0DA !important; font-weight: 500 !important; }
321
-
322
- /* === Drawer === */
323
- .aio-shell { position: relative; }
324
- .aio-drawer {
325
- width: 220px;
326
- border-right: 1px solid #262C35;
327
- background: #12161B;
328
- padding: 14px 10px !important;
329
- flex-shrink: 0;
330
- transition: left 0.2s ease;
331
  }
332
- .aio-drawer-heading {
333
- font-family: 'IBM Plex Mono', ui-monospace, monospace;
334
- font-size: 10px; text-transform: uppercase; letter-spacing: 0.07em;
335
- color: #7C8693;
336
- padding: 6px 8px 4px !important;
337
- margin: 0 !important;
338
  }
339
 
340
- /* Mode buttons */
341
- .aio-mode-btn { width: 100%; text-align: left; margin: 2px 0 !important; }
342
- .aio-mode-btn-active {
343
- background: #1A1F26 !important;
344
- color: #E0A458 !important;
345
- border-left: 3px solid #E0A458 !important;
346
  }
347
-
348
- /* Model status / settings panels */
349
- .aio-model-badge {
350
- padding: 9px 11px;
351
- border-radius: 6px;
352
- background: #1A1F26;
353
- border: 1px solid #262C35;
354
- font-size: 11.5px;
355
- font-family: 'IBM Plex Mono', ui-monospace, monospace;
356
- color: #7C8693;
357
  }
358
-
359
- /* Discord callout drawer-bottom community button. Warm amber-on-slate
360
- to match the Topaz palette; the arrow nudges right on hover so it
361
- reads as actionable without screaming. */
362
- .aio-discord-btn {
363
  display: flex;
364
- align-items: center;
365
  gap: 10px;
366
- padding: 10px 12px;
367
- margin: 4px 0;
368
- border-radius: 8px;
369
- background: linear-gradient(135deg, #1F2630 0%, #1A1F26 100%);
370
- border: 1px solid #2C3340;
371
- color: #E0A458 !important;
372
- font-size: 12.5px;
373
- font-weight: 500;
374
- text-decoration: none !important;
375
- transition: border-color 0.15s ease, background 0.15s ease, transform 0.15s ease;
376
- }
377
- .aio-discord-btn:hover {
378
- border-color: #E0A458;
379
- background: linear-gradient(135deg, #242C37 0%, #1F2630 100%);
380
- }
381
- .aio-discord-btn:hover .aio-discord-arrow { transform: translateX(3px); }
382
- .aio-discord-glyph { font-size: 14px; line-height: 1; }
383
- .aio-discord-arrow {
384
- margin-left: auto;
385
- color: #7C8693;
386
- transition: transform 0.15s ease, color 0.15s ease;
387
  }
388
- .aio-discord-btn:hover .aio-discord-arrow { color: #E0A458; }
389
-
390
- /* === Status banner === */
391
- .status-card {
392
- padding: 12px 16px;
393
- border-radius: 6px;
394
- background: #1A1F26;
395
- border: 1px solid #262C35;
396
  }
397
- .status-row { display: flex; gap: 14px; align-items: center; margin-bottom: 8px; flex-wrap: wrap; }
398
- .status-stage { font-weight: 600; color: #E0A458; }
399
- .status-meta { font-size: 12px; color: #7C8693; font-family: 'IBM Plex Mono', ui-monospace, monospace; }
400
- .status-bar { height: 4px; background: #262C35; border-radius: 99px; overflow: hidden; }
401
- .status-fill { height: 100%; background: #E0A458; transition: width .3s; }
402
- .status-mem { font-size: 11px; color: #7C8693; margin-top: 6px; font-family: 'IBM Plex Mono', ui-monospace, monospace; }
403
- .status-error {
404
- background: #3A1E20 !important;
405
- border-color: #F4A6A8 !important;
406
- color: #F4A6A8 !important;
407
  }
408
- .status-error .status-stage { color: #F4A6A8; }
409
-
410
- /* === Drawer toggle behavior at the desktop boundary === */
411
- @media (max-width: 1023px) {
412
- .aio-ham-label { display: flex; }
413
- .aio-drawer {
414
- position: fixed;
415
- top: 0; bottom: 0;
416
- left: -100%;
417
- z-index: 50;
418
- box-shadow: 4px 0 24px rgba(0,0,0,0.6);
419
- max-width: 80vw;
420
- overflow-y: auto;
421
- overflow-x: hidden;
422
- padding-top: 80px !important;
423
- }
424
- /* `.aio-shell.drawer-open` is toggled by the hamburger's inline JS.
425
- `body:has(:checked)` would be cleaner but Gradio prefixes user CSS
426
- with `.gradio-container .contain `, breaking ancestor selectors. */
427
- .aio-shell.drawer-open .aio-drawer { left: 0; }
428
- .aio-shell.drawer-open::before {
429
- content: ""; position: fixed; inset: 0;
430
- background: rgba(0,0,0,0.92); z-index: 45;
431
- backdrop-filter: blur(10px);
432
- -webkit-backdrop-filter: blur(10px);
433
- }
434
-
435
- /* Mobile sub-tweaks */
436
- .aio-mode-btn { font-size: 13px !important; padding: 7px 10px !important; }
437
- .aio-body [class*="row"] { flex-wrap: wrap !important; }
438
- .aio-body [class*="row"] > div { flex: 1 1 100% !important; min-width: 0 !important; }
439
  }
440
-
441
- @media (min-width: 1024px) {
442
- .aio-ham-label { display: none; }
443
  }
444
- """
445
-
446
 
447
- # ---------------------------------------------------------------------------
448
- # UI
449
- # ---------------------------------------------------------------------------
450
-
451
-
452
- _TOPAZ_THEME = gr.themes.Base(
453
- primary_hue=gr.themes.Color(
454
- c50="#FBE5C7", c100="#F5D29C", c200="#EFC174", c300="#E9B05A",
455
- c400="#E5A75B", c500="#E0A458", c600="#C68D3F", c700="#A6722E",
456
- c800="#7E5722", c900="#583C18", c950="#3A2810",
457
- ),
458
- neutral_hue=gr.themes.Color(
459
- c50="#E6E8EB", c100="#C9CDD3", c200="#ACB1B9", c300="#9097A0",
460
- c400="#7C8693", c500="#626972", c600="#4A4F58", c700="#363B43",
461
- c800="#262C35", c900="#1A1F26", c950="#12161B",
462
- ),
463
- font=(gr.themes.GoogleFont("IBM Plex Sans"), "ui-sans-serif", "system-ui", "sans-serif"),
464
- font_mono=(gr.themes.GoogleFont("IBM Plex Mono"), "ui-monospace", "monospace"),
465
- ).set(
466
- body_background_fill="#12161B",
467
- background_fill_primary="#12161B",
468
- background_fill_secondary="#1A1F26",
469
- block_background_fill="#1A1F26",
470
- block_label_background_fill="transparent",
471
- body_text_color="#E6E8EB",
472
- body_text_color_subdued="#7C8693",
473
- border_color_primary="#262C35",
474
- border_color_accent="#E0A458",
475
- button_primary_background_fill="#E0A458",
476
- button_primary_background_fill_hover="#F0B870",
477
- button_primary_text_color="#12161B",
478
- button_secondary_background_fill="#1A1F26",
479
- button_secondary_background_fill_hover="#232930",
480
- button_secondary_text_color="#E6E8EB",
481
- button_secondary_border_color="#262C35",
482
- input_background_fill="#12161B",
483
- input_border_color="#262C35",
484
- input_border_color_focus="#E0A458",
485
- error_background_fill="#3A1E20",
486
- error_text_color="#F4A6A8",
487
- slider_color="#E0A458",
488
- )
489
-
490
-
491
- _HEAD_HTML = """
492
- <script>
493
- (function(){
494
- if (window._aioDismissInstalled) return;
495
- window._aioDismissInstalled = true;
496
- document.addEventListener("click", function(e) {
497
- var s = document.querySelector(".aio-shell");
498
- if (!s || !s.classList.contains("drawer-open")) return;
499
- if (e.target.closest(".aio-drawer") || e.target.closest(".aio-ham-label")) return;
500
- s.classList.remove("drawer-open");
501
- var h = document.querySelector(".aio-header");
502
- if (h) h.classList.remove("drawer-elevated");
503
- var b = document.querySelector(".aio-ham-label");
504
- if (b) {
505
- b.textContent = "\\u2261";
506
- b.setAttribute("aria-expanded", "false");
507
- }
508
- });
509
- })();
510
- </script>
511
  """
512
 
513
-
514
- def build_app() -> gr.Blocks:
515
- with gr.Blocks(theme=_TOPAZ_THEME, title="LTX 2.3 Studio", css=_CUSTOM_CSS, head=_HEAD_HTML) as app:
516
- # Header: hamburger button toggles `.drawer-open` on `.aio-shell`.
517
- # The click-outside dismisser is registered via gr.Blocks(head=...)
518
- # below — Gradio strips <script> tags inside gr.HTML so it has to
519
- # live in <head> to actually run.
520
- gr.HTML(
521
- '<div class="aio-header">'
522
- ' <button type="button" class="aio-ham-label" '
523
- ' onclick="(function(b){var s=document.querySelector(\'.aio-shell\');'
524
- 'var o=s.classList.toggle(\'drawer-open\');'
525
- 'var h=document.querySelector(\'.aio-header\');'
526
- 'if(h)h.classList.toggle(\'drawer-elevated\',o);'
527
- 'b.textContent=o?\'\\u00d7\':\'\\u2261\';'
528
- 'b.setAttribute(\'aria-expanded\',o?\'true\':\'false\');})(this)" '
529
- ' aria-expanded="false" aria-label="Toggle navigation">≡</button>'
530
- ' <span class="aio-title">LTX 2.3 <span class="accent">Studio</span></span>'
531
- ' <span class="aio-mode-tag" id="aio-mode-tag">T2V</span>'
532
- '</div>'
533
- )
534
- gr.HTML(
535
- '<div class="aio-tipbar">'
536
- 'Built with care. '
537
- '<strong>Drop a <span class="aio-heart">♥</span> at the top</strong> to support it '
538
- '
539
- 'Follow <a href="https://huggingface.co/techfreakworm" target="_blank" rel="noopener noreferrer">@techfreakworm</a> '
540
- 'for what\'s next '
541
- '· '
542
- '<a href="https://discord.gg/qbn3exeEXa" target="_blank" rel="noopener noreferrer">Chat with the maker on Discord</a>'
543
- '</div>'
544
- )
545
-
546
- with gr.Row(elem_classes=["aio-shell"]):
547
- # Drawer (drawer behaves as fixed sidebar ≥1024 px;
548
- # absolute-positioned overlay <1024 px — see _CUSTOM_CSS).
549
- with gr.Column(scale=1, min_width=200, elem_classes=["aio-drawer"]):
550
- gr.Markdown("Modes", elem_classes=["aio-drawer-heading"])
551
- mode_buttons = {
552
- name: gr.Button(
553
- f"{m.icon} {m.label}",
554
- elem_classes=["aio-mode-btn"],
555
- variant="secondary",
556
- )
557
- for name, m in modes.MODE_REGISTRY.items()
558
- }
559
- gr.Markdown("Models", elem_classes=["aio-drawer-heading"])
560
- model_status = gr.HTML(_render_model_status_idle(), elem_id="aio-model-status")
561
- refresh_btn = gr.Button("Refresh", size="sm", variant="secondary")
562
- unload_btn = gr.Button("Unload all models", size="sm", variant="secondary")
563
- gr.Markdown("Settings", elem_classes=["aio-drawer-heading"])
564
- gr.Markdown(
565
- "Output: `comfyui/output/LTX2.3/`<br>"
566
- "Set `LTX23_AIO_VRAM=lowvram|normalvram|highvram` to override "
567
- "the auto-detected VRAM tier.",
568
- elem_classes=["aio-model-badge"],
569
- )
570
- gr.Markdown("Community", elem_classes=["aio-drawer-heading"])
571
- gr.HTML(
572
- '<a class="aio-discord-btn" href="https://discord.gg/qbn3exeEXa" '
573
- 'target="_blank" rel="noopener noreferrer">'
574
- '<span class="aio-discord-glyph">✨</span>'
575
- '<span>Chat with the maker on Discord</span>'
576
- '<span class="aio-discord-arrow">→</span>'
577
- '</a>'
578
- )
579
-
580
- # Body unchanged, still hosts the 6 mode tabs.
581
- with gr.Column(scale=4, elem_classes=["aio-body"]):
582
- handles, tabs_component = _render_mode_panels()
583
-
584
- # Wire generate buttons
585
- for name, h in handles.items():
586
- inputs = _collect_inputs_for_mode(name, h)
587
- h["generate_btn"].click(
588
- fn=_make_handler(name, h),
589
- inputs=inputs,
590
- outputs=[h["status"], h["video_out"]],
591
- )
592
-
593
- # JS to update the header mode tag without a server round-trip.
594
- # Each mode button injects a tiny on-click that rewrites #aio-mode-tag
595
- # and (on mobile) auto-collapses the drawer.
596
- _MODE_TAG_BY_NAME = {
597
- "t2v": "T2V", "a2v": "A2V", "i2v": "I2V",
598
- "lipsync": "LIPSYNC", "keyframe": "KEY", "style": "STYLE",
599
- }
600
- for name, btn in mode_buttons.items():
601
- tag = _MODE_TAG_BY_NAME.get(name, name.upper())
602
- btn.click(
603
- fn=lambda mode_id=name: gr.Tabs(selected=mode_id),
604
- inputs=None,
605
- outputs=[tabs_component],
606
- js=f"() => {{ "
607
- f"const el = document.getElementById('aio-mode-tag'); "
608
- f"if (el) el.textContent = {tag!r}; "
609
- f"if (window.matchMedia('(max-width: 1023px)').matches) {{ "
610
- f" document.querySelector('.aio-shell')?.classList.remove('drawer-open'); "
611
- f" document.querySelector('.aio-header')?.classList.remove('drawer-elevated'); "
612
- f" const hb = document.querySelector('.aio-ham-label'); "
613
- f" if (hb) {{ hb.textContent = '\\u2261'; hb.setAttribute('aria-expanded', 'false'); }} "
614
- f"}} return []; }}",
615
- )
616
-
617
- # Sidebar model info wiring
618
- refresh_btn.click(fn=_render_model_status, inputs=None, outputs=[model_status])
619
- unload_btn.click(fn=_unload_models, inputs=None, outputs=[model_status])
620
-
621
- return app
622
-
623
-
624
- def _render_model_status_idle() -> str:
625
- return (
626
- '<div class="aio-model-badge">device: detecting…<br>'
627
- "loaded: —<br>free: —</div>"
628
- )
629
-
630
-
631
- def _render_model_status() -> str:
632
- """Best-effort device + memory readout for the sidebar."""
633
- try:
634
- be = _get_backend() # ensure ComfyUI is loaded
635
- except Exception as exc:
636
- return f'<div class="aio-model-badge">backend not ready<br>{exc}</div>'
637
- try:
638
- import comfy.model_management as mm
639
- import torch
640
-
641
- device = mm.get_torch_device()
642
- free_gb = mm.get_free_memory(device) / (1024**3)
643
- if torch.backends.mps.is_available():
644
- # MPS unified memory: total physical = total system RAM. The
645
- # "recommended max" from torch.mps is a soft cap (~75% of total)
646
- # used by the allocator, but actual free can exceed it because
647
- # macOS shares RAM between CPU and GPU.
648
- try:
649
- import psutil
650
-
651
- total_gb = psutil.virtual_memory().total / (1024**3)
652
- except Exception:
653
- total_gb = torch.mps.recommended_max_memory() / (1024**3)
654
- cap_gb = torch.mps.recommended_max_memory() / (1024**3)
655
- label = "MPS (unified)"
656
- extra = f"<br>mps cap: {cap_gb:.1f} GB"
657
- elif torch.cuda.is_available():
658
- total_gb = torch.cuda.get_device_properties(0).total_memory / (1024**3)
659
- label = "CUDA"
660
- extra = ""
661
- else:
662
- total_gb = 0.0
663
- label = "CPU"
664
- extra = ""
665
- loaded = len(getattr(mm, "current_loaded_models", []))
666
- return (
667
- '<div class="aio-model-badge">'
668
- f"device: {label}<br>"
669
- f"loaded: {loaded} model(s)<br>"
670
- f"free: {free_gb:.1f} GB / {total_gb:.1f} GB total"
671
- f"{extra}"
672
- "</div>"
673
- )
674
- except Exception as exc:
675
- return f'<div class="aio-model-badge">memory probe failed: {exc}</div>'
676
-
677
-
678
- def _unload_models() -> str:
679
- try:
680
- import comfy.model_management as mm
681
- import torch
682
-
683
- mm.unload_all_models()
684
- if torch.backends.mps.is_available():
685
- torch.mps.empty_cache()
686
- if torch.cuda.is_available():
687
- torch.cuda.empty_cache()
688
- except Exception as exc:
689
- return f'<div class="aio-model-badge">unload failed: {exc}</div>'
690
- return _render_model_status()
691
-
692
-
693
- def _render_mode_panels() -> tuple[dict[str, dict], gr.Tabs]:
694
- """Render one (hidden-tab) panel per mode. Returns the component handles + the Tabs component."""
695
- handles: dict[str, dict] = {}
696
- with gr.Tabs(elem_classes=["aio-tabs"]) as tabs:
697
- for name, mode in modes.MODE_REGISTRY.items():
698
- with gr.Tab(label=f"{mode.icon} {mode.label}", id=name):
699
- handles[name] = _render_one_mode(name)
700
- return handles, tabs
701
-
702
-
703
- def _render_one_mode(name: str) -> dict:
704
- """Render a per-mode form. Returns component handles for the generate handler."""
705
- handles: dict = {"mode": name}
706
-
707
- with gr.Row():
708
- with gr.Column(scale=2, min_width=280):
709
- handles["prompt"] = gr.Textbox(
710
- label="Prompt", lines=4, placeholder="Describe the shot..."
711
- )
712
-
713
- # Mode-specific media inputs
714
- if name == "i2v":
715
- handles["image"] = gr.Image(label="Source image", type="filepath")
716
- elif name == "a2v":
717
- handles["audio"] = gr.Audio(label="Source audio", type="filepath")
718
- elif name == "lipsync":
719
- handles["image"] = gr.Image(label="Portrait", type="filepath")
720
- handles["audio"] = gr.Audio(label="Speech audio", type="filepath")
721
- elif name == "keyframe":
722
- handles["first_frame"] = gr.Image(label="First frame", type="filepath")
723
- handles["last_frame"] = gr.Image(label="Last frame", type="filepath")
724
- elif name == "style":
725
- gr.Markdown(
726
- "**Heads up — Style Transfer is the heaviest mode.** "
727
- "It runs the source video through pose detection AND adds "
728
- "every frame as conditioning, so even the Fast preset can "
729
- "blow the per-call GPU budget on free/anonymous tier. "
730
- "**A failed run still consumes daily quota.** "
731
- "For reliable runs: HF Pro account, resolution ≤ 1024×576, "
732
- "source video ≤ 8 s.",
733
- elem_classes=["aio-mode-warning"],
734
- )
735
- handles["image"] = gr.Image(label="Style reference", type="filepath")
736
- handles["input_video"] = gr.Video(label="Source video")
737
-
738
- handles["preset"] = ui.preset_bar()
739
-
740
- # Resolution — up to 4K, /32 step
741
- with gr.Row():
742
- handles["width"] = gr.Slider(
743
- 256, 4096, value=512, step=32, label="Width"
744
- )
745
- handles["height"] = gr.Slider(
746
- 256, 4096, value=768, step=32, label="Height"
747
- )
748
-
749
- # Length controlled in seconds (matches the master workflow's mxSlider).
750
- # Frames are derived: frames = round(seconds * fps / 8) * 8 + 1.
751
- with gr.Row():
752
- handles["seconds"] = gr.Slider(
753
- minimum=1, maximum=30, value=3, step=1,
754
- label="Length (seconds)",
755
- info="Frames are computed as 8·round(seconds·fps/8)+1 (LTX requires 8k+1)",
756
- )
757
- handles["fps"] = gr.Slider(8, 30, value=24, step=1, label="FPS")
758
-
759
- handles["frames_display"] = gr.Markdown("Frames: 73", elem_classes=["aio-frames-display"])
760
-
761
- with gr.Row():
762
- handles["seed"] = gr.Number(label="Seed", value=42, precision=0, minimum=0)
763
- handles["randomize_seed"] = gr.Checkbox(label="Randomize seed each run", value=True)
764
-
765
- with gr.Accordion("Advanced ▾", open=False):
766
- handles["lora"] = ui.lora_chrome(name)
767
- handles["negative_prompt"] = gr.Textbox(label="Negative prompt", lines=2)
768
-
769
- gr.Markdown(
770
- "**Tip for HF Spaces users:** Heavier configurations "
771
- "(Cinematic preset, high resolution, long videos) target local "
772
- "hardware and may abort mid-run on Spaces — burning quota with "
773
- "no output. Stay at Fast/Balanced + ≤ 1024×576 + ≤ 6 s output "
774
- "for safe Spaces runs.",
775
- elem_classes=["aio-hf-tip"],
776
- )
777
-
778
- handles["generate_btn"] = gr.Button("▶ Generate", variant="primary", size="lg")
779
-
780
- # Live frames-display update when seconds/fps change
781
- def _update_frames(seconds, fps):
782
- f = max(9, int(round(float(seconds) * float(fps) / 8) * 8) + 1)
783
- return f"**Frames:** {f} (`{seconds}s` × `{fps} fps`)"
784
-
785
- handles["seconds"].change(
786
- fn=_update_frames,
787
- inputs=[handles["seconds"], handles["fps"]],
788
- outputs=[handles["frames_display"]],
789
- )
790
- handles["fps"].change(
791
- fn=_update_frames,
792
- inputs=[handles["seconds"], handles["fps"]],
793
- outputs=[handles["frames_display"]],
794
- )
795
-
796
- with gr.Column(scale=2, min_width=280):
797
- handles["status"] = ui.status_banner()
798
- handles["video_out"] = gr.Video(label="Output", autoplay=True)
799
- handles["history"] = gr.Markdown("")
800
-
801
- return handles
802
-
803
-
804
- # ---------------------------------------------------------------------------
805
- # Backend wiring
806
- # ---------------------------------------------------------------------------
807
-
808
- _BACKEND: backend_module.ComfyUILibraryBackend | None = None
809
-
810
-
811
- def _get_backend() -> backend_module.ComfyUILibraryBackend:
812
- global _BACKEND
813
- if _BACKEND is None:
814
- _BACKEND = backend_module.ComfyUILibraryBackend()
815
- return _BACKEND
816
-
817
-
818
- # Must match the comfy_dir used in _bootstrap() — on Spaces this is
819
- # ~/comfyui (mirroring backend.py's _comfy_dir), otherwise repo-local.
820
- _COMFY_INPUT_DIR = (
821
- (pathlib.Path.home() / "comfyui" / "input")
822
- if _on_spaces()
823
- else pathlib.Path(__file__).parent / "comfyui" / "input"
824
- )
825
-
826
-
827
- def _stage_to_comfy_input(file_path) -> str | None:
828
- """Copy/stage a path into comfyui/input/ so ComfyUI's LoadImage etc. can find it."""
829
- if not file_path:
830
- return None
831
- if not isinstance(file_path, (str, pathlib.Path)):
832
- file_path = (
833
- file_path.get("name") or file_path.get("path") or file_path.get("orig_name")
834
- if isinstance(file_path, dict)
835
- else None
836
- )
837
- if not file_path:
838
- return None
839
- src = pathlib.Path(file_path)
840
- if not src.exists() or not src.is_file():
841
- print(f"[_stage] skip {file_path!r}", flush=True)
842
- return None
843
- _COMFY_INPUT_DIR.mkdir(parents=True, exist_ok=True)
844
- try:
845
- if src.resolve().is_relative_to(_COMFY_INPUT_DIR.resolve()):
846
- return src.name
847
- except (ValueError, OSError):
848
- pass
849
- dst = _COMFY_INPUT_DIR / src.name
850
- if not dst.exists() or dst.stat().st_size != src.stat().st_size:
851
- import shutil
852
-
853
- shutil.copy2(src, dst)
854
- return src.name
855
-
856
-
857
- PRESET_DURATION = {"Fast": 60, "Balanced": 120, "Quality": 300}
858
-
859
-
860
- _FRIENDLY_ERRORS: dict[str, tuple[str, str]] = {
861
- "gpu_timeout": (
862
- "Hit the GPU time limit",
863
- "This run took longer than the GPU budget. Try the Fast preset, a "
864
- "shorter video, or a smaller resolution — then click Generate again.",
865
- ),
866
- "expired_token": (
867
- "Session timed out",
868
- "Your sign-in session expired. Refresh the page and try again — "
869
- "you'll keep your spot in the GPU queue.",
870
- ),
871
- "illegal_duration": (
872
- "GPU budget too high",
873
- "The estimator asked for more GPU time than the server allows. "
874
- "Try Fast preset or a shorter video.",
875
- ),
876
- "unlogged": (
877
- "Sign-in not detected",
878
- "Make sure you're signed into huggingface.co (top-right avatar), "
879
- "then refresh this page. Pro accounts get 25 min of GPU per day.",
880
- ),
881
- "quota_exceeded": (
882
- "Daily GPU quota used up",
883
- "You've used today's GPU minutes. Wait for the rolling 24-hour "
884
- "reset, or upgrade Pro at huggingface.co/subscribe/pro for more.",
885
- ),
886
- "oom": (
887
- "Ran out of GPU memory",
888
- "Try a smaller resolution, fewer frames, or the Fast preset.",
889
- ),
890
- "interrupt": (
891
- "Cancelled",
892
- "Generation was cancelled. Click Generate to start a fresh run.",
893
- ),
894
- "download": (
895
- "Model download failed",
896
- "Couldn't fetch a required model file. Check your internet and try again.",
897
- ),
898
- }
899
-
900
-
901
- def _friendly_error(category: str, raw_message: str) -> tuple[str, str]:
902
- """Translate a backend error category into (title, body) the user can act on."""
903
- if category in _FRIENDLY_ERRORS:
904
- return _FRIENDLY_ERRORS[category]
905
- return (
906
- "Generation failed",
907
- "Something went wrong. Click Generate to retry, or check the Space "
908
- "logs if it keeps happening.",
909
- )
910
-
911
-
912
- def _seconds_to_frames(seconds: float, fps: int) -> int:
913
- return max(9, int(round(float(seconds) * float(fps) / 8) * 8) + 1)
914
-
915
-
916
- def _prune_old_outputs(output_dir: pathlib.Path, max_age_seconds: int = 4 * 3600) -> int:
917
- """Delete files under *output_dir* older than *max_age_seconds*; return count.
918
-
919
- HF Spaces ephemeral disk is 150 GB and preload already eats ~111 GB. Without
920
- this sweep, generations accumulate in `~/comfyui/output/` until the disk
921
- fills and the replica goes unhealthy (observed: stuck `RUNNING` with
922
- `replicas.current=0`). Per-file OSError is swallowed so one bad file
923
- doesn't abort a sweep that would otherwise free space.
924
- """
925
- if not output_dir.exists():
926
- return 0
927
- cutoff = time.time() - max_age_seconds
928
- deleted = 0
929
- for f in output_dir.rglob("*"):
930
- try:
931
- if not f.is_file():
932
- continue
933
- if f.stat().st_mtime < cutoff:
934
- f.unlink()
935
- deleted += 1
936
- except OSError:
937
- continue
938
- return deleted
939
-
940
-
941
- async def _on_generate(mode_name: str, *, progress: Any = None, **inputs: Any):
942
- """Generate handler — async generator yielding (status_html, video_path).
943
-
944
- `progress` is a `gr.Progress` instance injected by Gradio. It's the only
945
- progress channel that survives the @spaces.GPU subprocess boundary on HF
946
- Spaces; we forward it to the backend so ComfyUI's per-step counter renders
947
- a real progress bar instead of a generic Gradio spinner.
948
- """
949
- _comfy_dir_now = (
950
- (pathlib.Path.home() / "comfyui")
951
- if _on_spaces()
952
- else pathlib.Path(__file__).parent / "comfyui"
953
- )
954
- _prune_old_outputs(_comfy_dir_now / "output")
955
-
956
- mode = modes.MODE_REGISTRY[mode_name]
957
-
958
- fps = int(inputs.get("fps", 24))
959
- seconds = float(inputs.get("seconds", 3))
960
- frames = _seconds_to_frames(seconds, fps)
961
-
962
- # Seed: respect the explicit value unless the "randomize" checkbox is on.
963
- seed = int(inputs.get("seed", 42))
964
- if inputs.get("randomize_seed"):
965
- seed = random.randint(0, 2**31 - 1)
966
-
967
- params: dict[str, Any] = {
968
- "prompt": inputs.get("prompt", ""),
969
- "negative_prompt": inputs.get("negative_prompt", ""),
970
- "preset": str(inputs.get("preset", "Balanced")).lower(),
971
- "width": int(inputs.get("width", 512)),
972
- "height": int(inputs.get("height", 768)),
973
- "frames": frames,
974
- "fps": fps,
975
- "seed": seed,
976
- }
977
- for k in (
978
- "image", "audio", "first_frame", "last_frame", "input_video",
979
- "camera_lora", "camera_strength", "detailer_on", "detailer_strength",
980
- "ic_lora", "ic_strength", "pose_on", "audio_cfg", "image_strength",
981
- ):
982
- if k in inputs:
983
- params[k] = inputs[k]
984
-
985
- for key in ("image", "audio", "first_frame", "last_frame", "input_video"):
986
- if key in params and params[key]:
987
- staged = _stage_to_comfy_input(params[key])
988
- if staged is None:
989
- params.pop(key, None)
990
- else:
991
- params[key] = staged
992
-
993
- patches = mode.parameterize_fn(params)
994
- workflow = wf_module.load_template(mode_name)
995
- for patch in patches:
996
- wf_module.set_input(workflow, *patch)
997
-
998
- backend = _get_backend()
999
- preset = params["preset"] # already lowercased above
1000
-
1001
- async def _translate(event, started_at):
1002
- """Translate one backend event into Gradio (status_html, video) yields.
1003
-
1004
- Returns the tuple to yield, plus a flag indicating terminal state.
1005
- """
1006
- elapsed = time.time() - started_at
1007
- if isinstance(event, backend_module.DownloadEvent):
1008
- return (
1009
- ui.render_status(
1010
- stage_index=0,
1011
- stage_label=f"Downloading {event.filename}",
1012
- step=int(event.mb_done),
1013
- total_steps=int(max(event.mb_total, 1)),
1014
- elapsed_s=elapsed,
1015
- eta_s=0,
1016
- ),
1017
- gr.update(),
1018
- )
1019
- if isinstance(event, backend_module.ProgressEvent):
1020
- label = f"Diffusion (Stage {event.stage})"
1021
- eta = (elapsed / max(event.step, 1)) * (event.total_steps - event.step)
1022
- return (
1023
- ui.render_status(
1024
- stage_index=event.stage,
1025
- stage_label=label,
1026
- step=event.step,
1027
- total_steps=event.total_steps,
1028
- elapsed_s=elapsed,
1029
- eta_s=eta,
1030
- ),
1031
- gr.update(),
1032
- )
1033
- if isinstance(event, backend_module.OutputEvent):
1034
- video_update = event.video_path if event.video_path else gr.update()
1035
- return (ui._render_idle(), video_update)
1036
- if isinstance(event, backend_module.ErrorEvent):
1037
- title, body = _friendly_error(event.category, event.message)
1038
- return (
1039
- f'<div class="status-card status-error">'
1040
- f' <div class="status-row"><span class="status-stage">{title}</span></div>'
1041
- f" <div>{body}</div>"
1042
- f"</div>",
1043
- gr.update(),
1044
- )
1045
- return None
1046
-
1047
- # Single attempt. ZeroGPU-side abort (duration cap) and 401 expired-token
1048
- # surface as friendly messages via _friendly_error; user clicks Generate
1049
- # again to retry with a fresh request and fresh X-IP-Token.
1050
- started = time.time()
1051
- async for event in backend.submit(
1052
- mode_name, workflow,
1053
- preset=preset, duration_multiplier=1.0,
1054
- progress=progress,
1055
- ):
1056
- translated = await _translate(event, started)
1057
- if translated is not None:
1058
- yield translated
1059
-
1060
-
1061
- def _input_keys_for_mode(mode_name: str, h: dict) -> list[str]:
1062
- base = ["prompt", "preset", "width", "height", "seconds", "fps", "seed", "randomize_seed"]
1063
- if mode_name == "i2v":
1064
- base.append("image")
1065
- elif mode_name == "a2v":
1066
- base.append("audio")
1067
- elif mode_name == "lipsync":
1068
- base.extend(["image", "audio"])
1069
- elif mode_name == "keyframe":
1070
- base.extend(["first_frame", "last_frame"])
1071
- elif mode_name == "style":
1072
- base.extend(["image", "input_video"])
1073
- base.append("negative_prompt")
1074
- base.extend(["camera_lora", "camera_strength", "detailer_on", "detailer_strength"])
1075
- if h["lora"].ic_lora is not None:
1076
- base.extend(["ic_lora", "ic_strength"])
1077
- if h["lora"].pose_on is not None:
1078
- base.append("pose_on")
1079
- return base
1080
-
1081
-
1082
- def _collect_inputs_for_mode(mode_name: str, h: dict) -> list:
1083
- base = [
1084
- h["prompt"], h["preset"], h["width"], h["height"],
1085
- h["seconds"], h["fps"], h["seed"], h["randomize_seed"],
1086
- ]
1087
- if mode_name == "i2v":
1088
- base.append(h["image"])
1089
- elif mode_name == "a2v":
1090
- base.append(h["audio"])
1091
- elif mode_name == "lipsync":
1092
- base.extend([h["image"], h["audio"]])
1093
- elif mode_name == "keyframe":
1094
- base.extend([h["first_frame"], h["last_frame"]])
1095
- elif mode_name == "style":
1096
- base.extend([h["image"], h["input_video"]])
1097
- base.append(h["negative_prompt"])
1098
- base.extend([
1099
- h["lora"].camera_lora, h["lora"].camera_strength,
1100
- h["lora"].detailer_on, h["lora"].detailer_strength,
1101
- ])
1102
- if h["lora"].ic_lora is not None:
1103
- base.extend([h["lora"].ic_lora, h["lora"].ic_strength])
1104
- if h["lora"].pose_on is not None:
1105
- base.append(h["lora"].pose_on)
1106
- return base
1107
-
1108
-
1109
- def _make_handler(mode_name: str, h: dict):
1110
- keys = _input_keys_for_mode(mode_name, h)
1111
-
1112
- async def handler(*values, progress=gr.Progress()):
1113
- kwargs = dict(zip(keys, values, strict=False))
1114
- async for output in _on_generate(mode_name, progress=progress, **kwargs):
1115
- yield output
1116
-
1117
- return handler
1118
-
1119
-
1120
  if __name__ == "__main__":
1121
- # Gradio 5's file-access policy refuses to serve files outside cwd /
1122
- # tempdir / allowed_paths. ComfyUI writes generated videos to
1123
- # `<comfy_dir>/output/...` which is outside our cwd on Spaces, so
1124
- # whitelist that directory tree explicitly.
1125
- _on_spaces_at_launch = bool(os.environ.get("SPACES_ZERO_GPU"))
1126
- _comfy_dir_at_launch = (
1127
- (pathlib.Path.home() / "comfyui") if _on_spaces_at_launch
1128
- else pathlib.Path(__file__).parent / "comfyui"
1129
- )
1130
- _output_dir = _comfy_dir_at_launch / "output"
1131
- _output_dir.mkdir(parents=True, exist_ok=True)
1132
-
1133
- app = build_app()
1134
- app.launch(
1135
- server_name="0.0.0.0",
1136
- server_port=7860,
1137
- allowed_paths=[str(_output_dir)],
1138
- )
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  import gradio as gr
2
+ import os
3
 
4
+ # --- CSS STYLING (Replicating the dark purple UI) ---
5
+ # We inject custom CSS to make it match your screenshot design.
6
+ custom_css = """
7
+ /* Overall App Background */
8
+ .gradio-container {
9
+ background-color: #121212 !important;
10
+ color: #E0E0E0 !important;
11
+ font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
12
  }
13
 
14
+ /* Header Section */
15
+ .main-header {
16
  display: flex;
17
  align-items: center;
18
+ justify-content: space-between;
19
+ padding: 15px 20px;
20
+ background-color: #1e1e1e;
21
+ border-bottom: 1px solid #333;
22
+ }
23
+ .header-logo {
24
+ font-size: 1.5em;
25
+ font-weight: bold;
26
+ color: #FFC107; /* Yellow AMINA text */
27
+ }
28
+ .header-title {
29
+ color: #E0E0E0;
30
+ font-weight: 300;
31
+ }
32
+ .header-nav {
33
+ display: flex;
34
+ gap: 20px;
35
+ color: #BDBDBD;
36
  }
37
+ .header-nav a:hover {
38
+ color: #BB86FC; /* Purple accent on hover */
 
 
 
39
  }
40
+
41
+ /* Sidebar Styling */
42
+ .sidebar {
43
+ background-color: #1e1e1e !important;
44
+ padding: 20px !important;
45
+ border-right: 1px solid #333;
46
+ }
47
+ .new-project-btn button {
48
+ background-color: #BB86FC !important;
49
+ color: black !important;
50
+ border-radius: 8px !important;
51
+ font-weight: bold;
52
+ }
53
+ .recent-projects-label {
54
+ color: #757575;
55
+ margin-top: 30px;
56
+ margin-bottom: 10px;
57
+ font-size: 0.9em;
58
+ }
59
+ .project-item {
60
+ padding: 10px;
61
  border-radius: 5px;
62
+ color: #E0E0E0;
63
  cursor: pointer;
 
 
 
64
  }
65
+ .project-item:hover {
66
+ background-color: #333;
 
 
67
  }
68
+ .project-item.active {
69
+ background-color: #212121;
70
+ border-left: 3px solid #BB86FC;
 
 
 
 
 
 
71
  }
72
 
73
+ /* Main Content Area */
74
+ .content-area {
75
+ background-color: #121212 !important;
76
+ padding: 30px !important;
 
 
 
 
 
77
  }
78
+ .video-title {
79
+ font-size: 1.8em;
80
+ font-weight: bold;
81
+ color: #E0E0E0;
82
+ margin-bottom: 20px;
 
 
 
 
 
 
 
 
83
  }
 
84
 
85
+ /* Video Display Box */
86
+ .video-box {
87
+ background-color: #1e1e1e;
88
+ border-radius: 12px;
89
+ padding: 20px;
90
+ margin-bottom: 30px;
91
+ border: 1px solid #333;
 
 
 
92
  }
93
+ .video-box .wrap {
94
+ background-color: #000; /* Black background for video player */
 
 
 
 
 
 
 
 
 
95
  }
96
+ .video-description {
97
+ color: #BDBDBD;
98
+ font-size: 0.95em;
99
+ line-height: 1.5em;
100
+ margin-top: 15px;
 
101
  }
102
 
103
+ /* Prompt Input Box */
104
+ .prompt-box {
105
+ background-color: #1e1e1e;
106
+ border-radius: 12px;
107
+ padding: 15px;
108
+ border: 1px solid #333;
109
  }
110
+ .prompt-box textarea {
111
+ background-color: transparent !important;
112
+ color: #E0E0E0 !important;
113
+ border: none !important;
114
+ font-size: 1.1em;
 
 
 
 
 
115
  }
116
+ .prompt-box .label {
117
+ display: none !important; /* Hide standard label */
118
+ }
119
+ .tag-buttons {
 
120
  display: flex;
 
121
  gap: 10px;
122
+ margin-bottom: 10px;
123
+ }
124
+ .tag-btn {
125
+ background-color: #333 !important;
126
+ color: #E0E0E0 !important;
127
+ border: none !important;
128
+ border-radius: 20px !important;
129
+ padding: 5px 15px !important;
130
+ font-size: 0.85em;
131
+ cursor: pointer;
 
 
 
 
 
 
 
 
 
 
 
132
  }
133
+ .tag-btn:hover {
134
+ background-color: #444 !important;
 
 
 
 
 
 
135
  }
136
+ .icon-row {
137
+ display: flex;
138
+ justify-content: space-between;
139
+ align-items: center;
140
+ margin-top: 10px;
141
+ color: #BB86FC; /* Purple icon color */
 
 
 
 
142
  }
143
+ .icon-row .left-icons, .icon-row .right-icons {
144
+ display: flex;
145
+ gap: 15px;
146
+ font-size: 1.2em;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
147
  }
148
+ .icon-row svg {
149
+ cursor: pointer;
 
150
  }
 
 
151
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
152
  """
153
 
154
+ # --- GRADIO UI LAYOUT ---
155
+
156
+ with gr.Blocks(css=custom_css, theme=gr.themes.Base()) as demo:
157
+
158
+ # 1. Header Component (Custom HTML to match screenshot)
159
+ gr.HTML("""
160
+ <div class="main-header">
161
+ <div class="header-logo"><span style="color: #FFC107;">AMINA</span><br>X STUDIO</div>
162
+ <div class="header-nav">
163
+ <a href="#">🏠 Home</a>
164
+ <a href="#">📁 My Library</a>
165
+ <a href="#">🧩 Template</a>
166
+ <a href="#">💲 Pricing</a>
167
+ </div>
168
+ <div class="header-profile">
169
+ <img src="https://api.dicebear.com/8.x/adventurer/svg?seed=Alexander" style="width:40px; height:40px; border-radius:50%;" alt="Avatar">
170
+ </div>
171
+ </div>
172
+ """)
173
+
174
+ with gr.Row(elem_classes="content-area"):
175
+
176
+ # 2. Sidebar Component
177
+ with gr.Column(scale=1, elem_classes="sidebar"):
178
+ gr.Button("+ New Project", elem_classes="new-project-btn")
179
+ gr.Markdown("Recent Project", elem_classes="recent-projects-label")
180
+
181
+ # Project List Items (Clickable placeholders)
182
+ gr.HTML('<div class="project-item active">Wonders of the Amazon</div>')
183
+ gr.HTML('<div class="project-item">NBA Documentary</div>')
184
+ gr.HTML('<div class="project-item">Kalvin and the Jungle...</div>')
185
+
186
+ # 3. Main Content Component
187
+ with gr.Column(scale=4):
188
+ gr.Markdown("Wonders of the Amazon", elem_classes="video-title")
189
+
190
+ # Video Player & Description Box
191
+ with gr.Column(elem_classes="video-box"):
192
+ gr.Video(value="https://videos.pexels.com/video-files/8543534/8543534-sd_540_960_25fps.mp4", label="Project Video")
193
+ gr.Markdown("""
194
+ A slow, cinematic drone shot flying toward a massive, hidden waterfall deep within the Amazon rainforest.
195
+ The waterfall plunges over 200 feet down a moss-covered cliff face, crashing into a turquoise pool below.
196
+ Thick white mist rises from the base, catching golden sunlight. Surrounding the waterfall are ancient trees draped in
197
+ vines, bromeliads, and bright pink and orange orchids. A rainbow forms in the spray. Scarlet macaws fly across
198
+ the frame. Howler monkeys call in the distance. The camera slowly orbits around the waterfall, revealing the
199
+ lush canopy stretching endlessly in all directions. Warm, humid atmosphere, golden hour lighting,
200
+ photorealistic, 8K, BBC Planet Earth style,
201
+ """, elem_classes="video-description")
202
+
203
+ # Prompt Input Section
204
+ with gr.Column(elem_classes="prompt-box"):
205
+ gr.HTML("""
206
+ <div class="tag-buttons">
207
+ <button class="tag-btn">Unreal Landscape</button>
208
+ <button class="tag-btn">Cyberpunk Tokyo</button>
209
+ <button class="tag-btn">Animal/Wildlife</button>
210
+ <button class="tag-btn">Historical</button>
211
+ </div>
212
+ """)
213
+ # Actual Gradio Textbox, styled via CSS above
214
+ gr.Textbox(placeholder="What video do you have in mind? AMINA can help you out", lines=1, show_label=False)
215
+
216
+ # Bottom Icons (Placeholders for icons)
217
+ gr.HTML("""
218
+ <div class="icon-row">
219
+ <div class="left-icons">
220
+ <span>🖼️</span>
221
+ <span style="font-weight:bold; font-size:1em;">📺 16:9</span>
222
+ <span>⬇️</span>
223
+ <span>✨</span>
224
+ </div>
225
+ <div class="right-icons">
226
+ <span>💾</span>
227
+ <span>⬆️</span>
228
+ </div>
229
+ </div>
230
+ """)
231
+
232
+ # --- LAUNCH ---
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
233
  if __name__ == "__main__":
234
+ # Important: Share=True makes it accessible via public URL
235
+ demo.launch(server_name="0.0.0.0", server_port=7860)