daKhosa commited on
Commit
2828873
·
1 Parent(s): cb6f99f

Replace Wan2GP wrapper with direct LTX pipeline

Browse files
Files changed (2) hide show
  1. app.py +349 -412
  2. requirements.txt +9 -67
app.py CHANGED
@@ -1,453 +1,390 @@
1
- """
2
- Sulphur — Image to Video (HF Spaces).
3
- Clones Wan2GP and downloads models on first run.
4
- WanGPSession is created once on the first GPU call and kept alive so the model
5
- stays in memory — subsequent generations skip the full reload.
6
- """
7
-
8
- import io
9
  import os
10
- import sys
11
- import queue
12
  import subprocess
13
- import shutil
14
  import tempfile
15
- import threading
16
- import json
17
- import time
18
  from pathlib import Path
19
 
 
 
 
20
  import gradio as gr
 
21
  import spaces
 
 
22
 
23
- _HF_TOKEN = os.environ.get("HF_TOKEN")
24
- _PERSISTENT = Path("/data") if Path("/data").exists() else Path(tempfile.gettempdir())
25
- WAN2GP_ROOT = _PERSISTENT / "Wan2GP"
26
- CKPTS_DIR = WAN2GP_ROOT / "ckpts"
27
- LORAS_DIR = WAN2GP_ROOT / "loras" / "ltx2"
28
- FINETUNES_DIR = WAN2GP_ROOT / "finetunes"
29
- UPLOADS_DIR = _PERSISTENT / "sulphur_uploads"
30
 
31
- SULPHUR_ASSETS = [
32
- ("SulphurAI/Sulphur-2-base", "sulphur_distil_bf16.safetensors", CKPTS_DIR),
33
- ]
34
- LTX_ASSETS = [
35
- ("SulphurAI/Sulphur-2-base", "experimental/sulphur_experimental_lora_v1.safetensors", LORAS_DIR),
36
- ("DeepBeepMeep/LTX-2", "ltx-2.3-22b-distilled-lora-384.safetensors", LORAS_DIR),
37
- ("DeepBeepMeep/LTX-2", "ltx-2.3-22b_vae.safetensors", CKPTS_DIR),
38
- ("DeepBeepMeep/LTX-2", "ltx-2.3-22b_text_embedding_projection.safetensors", CKPTS_DIR),
39
- ("DeepBeepMeep/LTX-2", "ltx-2.3-22b_embeddings_connector.safetensors", CKPTS_DIR),
40
- ]
41
 
42
- WAN2GP_SHARED_ASSETS = [
43
- "pose/dw-ll_ucoco_384.onnx",
44
- "pose/yolox_l.onnx",
45
- "scribble/netG_A_latest.pth",
46
- "flow/raft-things.pth",
47
- "depth/depth_anything_v2_vitl.pth",
48
- "wav2vec/config.json",
49
- "wav2vec/feature_extractor_config.json",
50
- "wav2vec/model.safetensors",
51
- "wav2vec/preprocessor_config.json",
52
- "wav2vec/special_tokens_map.json",
53
- "wav2vec/tokenizer_config.json",
54
- "wav2vec/vocab.json",
55
- "chinese-wav2vec2-base/config.json",
56
- "chinese-wav2vec2-base/pytorch_model.bin",
57
- "chinese-wav2vec2-base/preprocessor_config.json",
58
- "roformer/model_bs_roformer_ep_317_sdr_12.9755.ckpt",
59
- "roformer/model_bs_roformer_ep_317_sdr_12.9755.yaml",
60
- "roformer/download_checks.json",
61
- "pyannote/pyannote_model_wespeaker-voxceleb-resnet34-LM.bin",
62
- "pyannote/pytorch_model_segmentation-3.0.bin",
63
- "det_align/detface.pt",
64
- "rife4.26.pkl",
65
- "mask/sam_vit_h_4b8939_fp16.safetensors",
66
- "mask/matanyone.safetensors",
67
- "mask/config.json",
68
- ]
69
 
70
- LTX_RUNTIME_ASSETS = [
71
- "ltx-2.3-spatial-upscaler-x2-1.1.safetensors",
72
- "ltx-2.3-temporal-upscaler-x2-1.0.safetensors",
73
- "ltx-2.3-22b_audio_vae.safetensors",
74
- "ltx-2.3-22b_vocoder.safetensors",
75
- "gemma-3-12b-it-qat-q4_0-unquantized/added_tokens.json",
76
- "gemma-3-12b-it-qat-q4_0-unquantized/chat_template.json",
77
- "gemma-3-12b-it-qat-q4_0-unquantized/config_light.json",
78
- "gemma-3-12b-it-qat-q4_0-unquantized/generation_config.json",
79
- "gemma-3-12b-it-qat-q4_0-unquantized/preprocessor_config.json",
80
- "gemma-3-12b-it-qat-q4_0-unquantized/processor_config.json",
81
- "gemma-3-12b-it-qat-q4_0-unquantized/special_tokens_map.json",
82
- "gemma-3-12b-it-qat-q4_0-unquantized/tokenizer.json",
83
- "gemma-3-12b-it-qat-q4_0-unquantized/tokenizer.model",
84
- "gemma-3-12b-it-qat-q4_0-unquantized/tokenizer_config.json",
85
- ]
86
 
87
- SULPHUR_FINETUNE = {
88
- "model": {
89
- "name": "Sulphur 2 Base",
90
- "visible": True,
91
- "architecture": "ltx2_22B",
92
- "parent_model_type": "ltx2_22B",
93
- "description": "LTX-2.3 fine-tuned i2v. Distilled checkpoint.",
94
- # Full distilled model — do NOT also preload the rank-768 LoRA (README: use one or the other)
95
- "URLs": [str(CKPTS_DIR / "sulphur_distil_bf16.safetensors")],
96
- "preload_URLs": [],
97
- },
98
- "num_inference_steps": 8,
99
- "video_length": 81,
100
- "resolution": "832x480",
101
- "guidance_scale": 3.5,
102
- "alt_guidance_scale": 3.5,
103
  }
104
 
105
- _setup_lock = threading.Lock()
106
- _setup_done = False
107
-
108
- _session = None
109
- _session_lock = threading.Lock()
110
-
111
- WAN2GP_PROFILE = os.environ.get("WAN2GP_PROFILE", "1")
112
- WAN2GP_PRELOAD_MB = os.environ.get("WAN2GP_PRELOAD_MB", "0")
113
- WAN2GP_PERC_RESERVED_MEM_MAX = os.environ.get("WAN2GP_PERC_RESERVED_MEM_MAX", "0.49")
114
-
115
-
116
- def _download(repo_id, filename, dest_dir):
117
- from huggingface_hub import hf_hub_download
118
- dest_dir.mkdir(parents=True, exist_ok=True)
119
- dest = dest_dir / Path(filename).name # flat — strip any subfolder
120
- if dest.exists():
121
- print(f"[download] cached: {dest.name}")
122
- return
123
- print(f"[download] {repo_id}/{filename}")
124
- hf_hub_download(repo_id=repo_id, filename=filename,
125
- local_dir=str(dest_dir), token=_HF_TOKEN)
126
- # hf_hub_download preserves subfolder structure; flatten to dest_dir root
127
- downloaded = dest_dir / filename
128
- if downloaded.exists() and not dest.exists():
129
- shutil.move(str(downloaded), str(dest))
130
-
131
-
132
- def _download_preserve(repo_id, filename, dest_root):
133
- from huggingface_hub import hf_hub_download
134
- dest = dest_root / filename
135
- if dest.exists():
136
- print(f"[download] cached: {filename}")
137
- return
138
- print(f"[download] {repo_id}/{filename}")
139
- hf_hub_download(repo_id=repo_id, filename=filename,
140
- local_dir=str(dest_root), token=_HF_TOKEN)
141
-
142
-
143
- def setup():
144
- global _setup_done
145
- with _setup_lock:
146
- if _setup_done:
147
- return
148
- _setup_done = True
149
-
150
- if not (WAN2GP_ROOT / "shared" / "api.py").exists():
151
- WAN2GP_ROOT.mkdir(parents=True, exist_ok=True)
152
- print("[setup] Cloning Wan2GP...")
153
- subprocess.run(
154
- ["git", "clone", "--depth=1",
155
- "https://github.com/deepbeepmeep/Wan2GP.git", str(WAN2GP_ROOT)],
156
- check=True,
157
- )
158
 
159
- for repo, fname, dest in SULPHUR_ASSETS + LTX_ASSETS:
160
- _download(repo, fname, dest)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
161
 
162
- for fname in WAN2GP_SHARED_ASSETS:
163
- _download_preserve("DeepBeepMeep/Wan2.1", fname, CKPTS_DIR)
164
 
165
- for fname in LTX_RUNTIME_ASSETS:
166
- _download_preserve("DeepBeepMeep/LTX-2", fname, CKPTS_DIR)
167
 
168
- # Gemma text encoder — must stay in its subfolder (Wan2GP looks there by name)
169
- _gemma_folder = "gemma-3-12b-it-qat-q4_0-unquantized"
170
- _gemma_file = f"{_gemma_folder}_quanto_bf16_int8.safetensors"
171
- gemma_dest = CKPTS_DIR / _gemma_folder / _gemma_file
172
- if not gemma_dest.exists():
173
- from huggingface_hub import hf_hub_download
174
- print("[download] Gemma text encoder...")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
175
  hf_hub_download(
176
- repo_id="DeepBeepMeep/LTX-2",
177
- filename=f"{_gemma_folder}/{_gemma_file}",
178
- local_dir=str(CKPTS_DIR),
179
- token=_HF_TOKEN,
180
  )
181
- else:
182
- print("[download] cached: Gemma text encoder")
 
183
 
184
- FINETUNES_DIR.mkdir(parents=True, exist_ok=True)
185
- (FINETUNES_DIR / "sulphur_2_base.json").write_text(json.dumps(SULPHUR_FINETUNE, indent=2))
186
- _write_wangp_config()
187
- print("[setup] Done.")
188
 
189
- RESOLUTIONS = ["832x480", "480x832", "640x640", "1024x576", "576x1024"]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
190
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
191
 
192
- def _coerce_number(value, fallback):
193
- try:
194
- parsed = float(value)
195
- except (TypeError, ValueError):
196
- return fallback
197
- return int(parsed) if parsed.is_integer() else parsed
198
 
 
199
 
200
- def _write_wangp_config():
201
- """Tell WanGP to preload once at runtime import and keep the model resident."""
202
- config_path = WAN2GP_ROOT / "wgp_config.json"
203
- try:
204
- existing = json.loads(config_path.read_text()) if config_path.exists() else {}
205
- except Exception:
206
- existing = {}
207
-
208
- existing.update({
209
- "profile": _coerce_number(WAN2GP_PROFILE, 2),
210
- "preload_model_policy": ["P"],
211
- "preload_in_VRAM": _coerce_number(WAN2GP_PRELOAD_MB, 0),
212
- "perc_reserved_mem_max": _coerce_number(WAN2GP_PERC_RESERVED_MEM_MAX, 0.45),
213
- "transformer_filename": "sulphur_2_base",
214
- "transformer_types": ["sulphur_2_base"],
215
- "text_encoder_quantization": "int8",
216
- "transformer_quantization": "bf16",
217
- "transformer_dtype_policy": "bf16",
218
- "attention_mode": existing.get("attention_mode", "sdpa"),
219
- "compile": existing.get("compile", ""),
220
- "vae_config": existing.get("vae_config", 0),
221
- "clear_file_list": existing.get("clear_file_list", 5),
222
- "UI_theme": existing.get("UI_theme", "default"),
223
- })
224
- config_path.write_text(json.dumps(existing, indent=2))
225
-
226
-
227
- def _wangp_cli_args():
228
- args = [
229
- "--profile", str(WAN2GP_PROFILE),
230
- "--preload", str(WAN2GP_PRELOAD_MB),
231
- "--perc-reserved-mem-max", str(WAN2GP_PERC_RESERVED_MEM_MAX),
232
- "--lock-config",
233
- "--lock-model",
234
- ]
235
- attention = os.environ.get("WAN2GP_ATTENTION", "").strip()
236
- if attention:
237
- args += ["--attention", attention]
238
- return args
239
-
240
-
241
- setup()
242
-
243
-
244
- def cache_uploaded_image(image):
245
- if not image:
246
- return None
247
- src = Path(str(image)).resolve()
248
- if not src.exists():
249
- raise gr.Error("Uploaded image is no longer available. Please upload it again.")
250
- UPLOADS_DIR.mkdir(parents=True, exist_ok=True)
251
- suffix = src.suffix or ".png"
252
- dest = UPLOADS_DIR / f"input_{time.time_ns()}{suffix}"
253
- shutil.copy2(src, dest)
254
- return str(dest)
255
-
256
-
257
- def _ensure_session():
258
- """Create WanGPSession on first call; reuse on all subsequent calls."""
259
- global _session
260
- if _session is not None:
261
- return _session
262
- with _session_lock:
263
- if _session is not None:
264
- return _session
265
- sys.path.insert(0, str(WAN2GP_ROOT))
266
- os.chdir(WAN2GP_ROOT)
267
- from shared.api import WanGPSession
268
- out_dir = Path("/tmp/wan2gp_output")
269
- out_dir.mkdir(parents=True, exist_ok=True)
270
- _session = WanGPSession(
271
- root=WAN2GP_ROOT,
272
- output_dir=out_dir,
273
- cli_args=_wangp_cli_args(),
274
- console_output=True,
275
- ).ensure_ready()
276
- return _session
277
-
278
-
279
- class _QueueWriter(io.TextIOBase):
280
- """Forward print() output to a queue while also passing through to real stdout."""
281
- def __init__(self, q, real_out):
282
- self._q = q
283
- self._real = real_out
284
- self._buf = ""
285
-
286
- def write(self, s):
287
- self._real.write(s)
288
- self._buf += s
289
- # flush complete lines (Wan2GP uses both \n and \r for progress)
290
- while True:
291
- for sep in ("\r\n", "\n", "\r"):
292
- idx = self._buf.find(sep)
293
- if idx >= 0:
294
- line = self._buf[:idx]
295
- self._buf = self._buf[idx + len(sep):]
296
- if line.strip():
297
- self._q.put(line)
298
- break
299
- else:
300
- break
301
- return len(s)
302
-
303
- def flush(self):
304
- self._real.flush()
305
 
306
 
307
- @spaces.GPU
308
- def generate_video(image_path_cached, prompt, resolution, steps, guidance_scale, seconds, seed):
309
- if image_path_cached is None:
310
- raise gr.Error("Please upload an image.")
311
- if not prompt.strip():
312
- raise gr.Error("Please enter a prompt.")
313
-
314
- frames = int(seconds * 25 // 8) * 8 + 1
315
- image_path = str(Path(str(image_path_cached)).resolve())
316
- if not Path(image_path).exists():
317
- raise gr.Error("Uploaded image is no longer available. Please upload it again.")
318
- out_dir = Path(tempfile.mkdtemp())
319
- out_file = out_dir / "output.mp4"
320
-
321
- task = {
322
- "model_type": "sulphur_2_base",
323
- "base_model_type": "sulphur_2_base",
324
- "prompt": prompt,
325
- "image_start": image_path,
326
- "num_inference_steps": int(steps),
327
- "guidance_scale": float(guidance_scale),
328
- "resolution": resolution,
329
- "video_length": frames,
330
- "seed": int(seed),
331
- "image_prompt_type": "S",
332
- "input_video_strength": 1.0,
333
- "activated_loras": ["sulphur_experimental_lora_v1.safetensors"],
334
- "loras_multipliers": ["0.5"],
335
- }
336
 
337
- log_lines = []
338
- result = None
339
 
340
- def _append_log(line):
341
- stripped = str(line or "").strip()
342
- if not stripped:
343
- return
344
- if log_lines and ("%" in stripped or "it/s" in stripped or "step" in stripped.lower()):
345
- log_lines[-1] = stripped
346
- else:
347
- log_lines.append(stripped)
348
 
349
- _append_log("[startup] Loading WanGP session...")
350
- yield None, "\n".join(log_lines[-30:])
351
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
352
  try:
353
- session = _ensure_session()
354
- _append_log("[startup] WanGP session ready. Starting generation...")
355
- yield None, "\n".join(log_lines[-30:])
356
-
357
- job = session.submit_task(task)
358
- last_event_at = time.time()
359
- while True:
360
- event = job.events.get(timeout=0.5)
361
- if event is None:
362
- if job.done:
363
- break
364
- if time.time() - last_event_at > 30:
365
- _append_log("[waiting] WanGP is still running...")
366
- last_event_at = time.time()
367
- yield None, "\n".join(log_lines[-30:])
368
- continue
369
-
370
- last_event_at = time.time()
371
- if event.kind == "stream":
372
- data = event.data
373
- _append_log(getattr(data, "text", data))
374
- elif event.kind == "progress":
375
- progress = event.data
376
- current = getattr(progress, "current_step", None)
377
- total = getattr(progress, "total_steps", None)
378
- phase = getattr(progress, "phase", "progress")
379
- status = getattr(progress, "status", "")
380
- if current is not None and total is not None:
381
- _append_log(f"[{phase}] step {current}/{total} {status}")
382
- else:
383
- _append_log(f"[{phase}] {status}")
384
- elif event.kind == "error":
385
- _append_log(f"[ERROR] {event.data}")
386
- elif event.kind == "completed":
387
- result = event.data
388
- break
389
-
390
- yield None, "\n".join(log_lines[-30:])
391
-
392
- if result is None:
393
- result = job.result(timeout=1)
394
  except Exception as exc:
395
- yield None, "\n".join(log_lines) + f"\n\n[ERROR] {exc}"
396
- return
397
-
398
- output_file = None
399
-
400
- if result and result.artifacts:
401
- src = result.artifacts[0].path
402
- if src and Path(src).exists():
403
- output_file = str(src)
404
-
405
- if output_file is None:
406
- for search_dir in [out_dir, Path("/tmp/wan2gp_output")]:
407
- candidates = sorted(search_dir.glob("**/*.mp4"),
408
- key=lambda f: f.stat().st_mtime, reverse=True)
409
- if candidates:
410
- output_file = str(candidates[0])
411
- break
412
-
413
- if output_file:
414
- final = tempfile.NamedTemporaryFile(suffix=".mp4", delete=False)
415
- shutil.copy2(output_file, final.name)
416
- yield final.name, "\n".join(log_lines) + "\n\n[DONE]"
417
- else:
418
- errors = getattr(result, "errors", None)
419
- yield None, "\n".join(log_lines) + f"\n\n[ERROR] No output found. Errors: {errors}"
420
-
421
-
422
- with gr.Blocks(title="Sulphur — Image to Video") as demo:
423
- gr.Markdown("# Sulphur — Image to Video\nUsing New 'Experimental' Lora v1")
424
- image_state = gr.State(None)
425
  with gr.Row():
426
- with gr.Column(scale=1):
427
- image_in = gr.Image(type="filepath", label="Input Image")
428
- prompt_in = gr.Textbox(label="Prompt", placeholder="Describe the motion…", lines=3)
 
 
 
 
 
 
 
 
 
 
 
 
 
429
  with gr.Accordion("Advanced", open=False):
430
- resolution_dd = gr.Dropdown(RESOLUTIONS, value="832x480", label="Resolution")
431
- steps_sl = gr.Slider(1, 50, value=8, step=1, label="Steps")
432
- guidance_sl = gr.Slider(1.0, 10.0, value=5.0, step=0.5, label="Guidance Scale")
433
- seconds_sl = gr.Slider(1, 5, value=3, step=1, label="Seconds")
434
- seed_num = gr.Number(value=-1, label="Seed (-1 = random)", precision=0)
435
- run_btn = gr.Button("Generate", variant="primary")
436
- with gr.Column(scale=1):
437
- video_out = gr.Video(label="Output Video")
438
- log_out = gr.Textbox(label="Log", lines=10, interactive=False)
439
-
440
- image_in.change(
441
- fn=cache_uploaded_image,
442
- inputs=[image_in],
443
- outputs=[image_state],
444
- )
445
 
446
- run_btn.click(
 
 
 
 
 
 
447
  fn=generate_video,
448
- inputs=[image_state, prompt_in, resolution_dd, steps_sl, guidance_sl, seconds_sl, seed_num],
449
- outputs=[video_out, log_out],
450
  )
451
 
 
452
  if __name__ == "__main__":
453
- demo.launch(theme=gr.themes.Soft())
 
1
+ import json
2
+ import logging
 
 
 
 
 
 
3
  import os
4
+ import random
5
+ import struct
6
  import subprocess
7
+ import sys
8
  import tempfile
 
 
 
9
  from pathlib import Path
10
 
11
+ os.environ["TORCH_COMPILE_DISABLE"] = "1"
12
+ os.environ["TORCHDYNAMO_DISABLE"] = "1"
13
+
14
  import gradio as gr
15
+ import numpy as np
16
  import spaces
17
+ import torch
18
+ from huggingface_hub import hf_hub_download
19
 
 
 
 
 
 
 
 
20
 
21
+ LTX_REPO_URL = "https://github.com/Lightricks/LTX-2.git"
22
+ LTX_COMMIT_SHA = "ae855f8538843825f9015a419cf4ba5edaf5eec2"
 
 
 
 
 
 
 
 
23
 
24
+ HF_TOKEN = os.environ.get("HF_TOKEN")
25
+ PERSISTENT = Path("/data") if Path("/data").exists() else Path(tempfile.gettempdir())
26
+ ASSETS_DIR = PERSISTENT / "sulphur_ltx"
27
+ LTX_REPO_DIR = PERSISTENT / "LTX-2"
28
+ OUTPUT_DIR = PERSISTENT / "sulphur_outputs"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
29
 
30
+ MAX_SEED = np.iinfo(np.int32).max
31
+ DEFAULT_FRAME_RATE = 24.0
32
+ DEFAULT_PROMPT = "Make this image come alive with cinematic motion, smooth animation."
 
 
 
 
 
 
 
 
 
 
 
 
 
33
 
34
+ RESOLUTIONS = {
35
+ "high": {"16:9": (1536, 1024), "9:16": (1024, 1536), "1:1": (1024, 1024)},
36
+ "low": {"16:9": (768, 512), "9:16": (512, 768), "1:1": (768, 768)},
 
 
 
 
 
 
 
 
 
 
 
 
 
37
  }
38
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
39
 
40
+ def _run(cmd, cwd=None, check=True):
41
+ print("[setup]", " ".join(str(part) for part in cmd))
42
+ return subprocess.run(cmd, cwd=cwd, check=check)
43
+
44
+
45
+ def install_ltx_runtime():
46
+ _run([sys.executable, "-m", "pip", "install", "xformers==0.0.32.post2", "--no-build-isolation"], check=False)
47
+
48
+ if not (LTX_REPO_DIR / ".git").exists():
49
+ LTX_REPO_DIR.parent.mkdir(parents=True, exist_ok=True)
50
+ if LTX_REPO_DIR.exists():
51
+ raise RuntimeError(f"{LTX_REPO_DIR} exists but is not a git checkout")
52
+ _run(["git", "init", str(LTX_REPO_DIR)])
53
+ _run(["git", "remote", "add", "origin", LTX_REPO_URL], cwd=LTX_REPO_DIR)
54
+
55
+ _run(["git", "fetch", "--depth", "1", "origin", LTX_COMMIT_SHA], cwd=LTX_REPO_DIR)
56
+ _run(["git", "checkout", LTX_COMMIT_SHA], cwd=LTX_REPO_DIR)
57
+
58
+ _run(
59
+ [
60
+ sys.executable,
61
+ "-m",
62
+ "pip",
63
+ "install",
64
+ "--force-reinstall",
65
+ "--no-deps",
66
+ "-e",
67
+ str(LTX_REPO_DIR / "packages" / "ltx-core"),
68
+ "-e",
69
+ str(LTX_REPO_DIR / "packages" / "ltx-pipelines"),
70
+ ]
71
+ )
72
+
73
+ sys.path.insert(0, str(LTX_REPO_DIR / "packages" / "ltx-pipelines" / "src"))
74
+ sys.path.insert(0, str(LTX_REPO_DIR / "packages" / "ltx-core" / "src"))
75
+
76
 
77
+ install_ltx_runtime()
 
78
 
79
+ torch._dynamo.config.suppress_errors = True
80
+ torch._dynamo.config.disable = True
81
 
82
+ from ltx_core.loader import LTXV_LORA_COMFY_RENAMING_MAP, LoraPathStrengthAndSDOps
83
+ from ltx_core.loader.primitives import StateDict
84
+ from ltx_core.loader.sft_loader import SafetensorsStateDictLoader
85
+ from ltx_core.model.video_vae import TilingConfig, get_video_chunks_number
86
+ from ltx_core.quantization import QuantizationPolicy
87
+ from ltx_pipelines.distilled import DistilledPipeline
88
+ from ltx_pipelines.utils.args import ImageConditioningInput
89
+ from ltx_pipelines.utils.media_io import encode_video
90
+
91
+
92
+ try:
93
+ from ltx_core.model.transformer import attention as _attn_mod
94
+ from xformers.ops import memory_efficient_attention as _memory_efficient_attention
95
+
96
+ _attn_mod.memory_efficient_attention = _memory_efficient_attention
97
+ print("[ATTN] xformers memory_efficient_attention enabled")
98
+ except Exception as exc:
99
+ print(f"[ATTN] xformers patch failed: {type(exc).__name__}: {exc}")
100
+
101
+ try:
102
+ from xformers.ops.fmha import _set_use_fa3
103
+
104
+ _set_use_fa3(False)
105
+ print("[ATTN] xformers FA3 dispatch disabled")
106
+ except Exception as exc:
107
+ print(f"[ATTN] FA3 disable failed: {type(exc).__name__}: {exc}")
108
+
109
+
110
+ _SAFETENSORS_DTYPE_MAP = {
111
+ "F64": torch.float64,
112
+ "F32": torch.float32,
113
+ "F16": torch.float16,
114
+ "BF16": torch.bfloat16,
115
+ "F8_E5M2": torch.float8_e5m2,
116
+ "F8_E4M3": torch.float8_e4m3fn,
117
+ "I64": torch.int64,
118
+ "I32": torch.int32,
119
+ "I16": torch.int16,
120
+ "I8": torch.int8,
121
+ "U8": torch.uint8,
122
+ "BOOL": torch.bool,
123
+ }
124
+
125
+
126
+ def _patched_safetensors_load(self, path, sd_ops, device=None):
127
+ sd = {}
128
+ size = 0
129
+ dtype = set()
130
+ device = device or torch.device("cpu")
131
+ model_paths = path if isinstance(path, list | tuple) else [path]
132
+ for shard_path in model_paths:
133
+ with open(shard_path, "rb") as reader:
134
+ header_len = struct.unpack("<Q", reader.read(8))[0]
135
+ header = json.loads(reader.read(header_len).decode("utf-8"))
136
+ data_base = 8 + header_len
137
+ for name, meta in header.items():
138
+ if name == "__metadata__":
139
+ continue
140
+ expected_name = name if sd_ops is None else sd_ops.apply_to_key(name)
141
+ if expected_name is None:
142
+ continue
143
+ start, end = meta["data_offsets"]
144
+ reader.seek(data_base + start)
145
+ raw = reader.read(end - start)
146
+ tensor = torch.frombuffer(
147
+ bytearray(raw), dtype=_SAFETENSORS_DTYPE_MAP[meta["dtype"]]
148
+ ).reshape(meta["shape"])
149
+ tensor = tensor.to(device=device, non_blocking=True, copy=False)
150
+ kvs = ((expected_name, tensor),) if sd_ops is None else sd_ops.apply_to_key_value(expected_name, tensor)
151
+ for key, value in kvs:
152
+ size += value.nbytes
153
+ dtype.add(value.dtype)
154
+ sd[key] = value
155
+ return StateDict(sd=sd, device=device, size=size, dtype=dtype)
156
+
157
+
158
+ SafetensorsStateDictLoader.load = _patched_safetensors_load
159
+ print("[FUSE-PATCH] Safetensors loader uses chunked reads")
160
+
161
+ logging.getLogger().setLevel(logging.INFO)
162
+
163
+
164
+ def _download(repo_id, filename, local_dir):
165
+ local_dir = Path(local_dir)
166
+ local_dir.mkdir(parents=True, exist_ok=True)
167
+ path = Path(
168
  hf_hub_download(
169
+ repo_id=repo_id,
170
+ filename=filename,
171
+ local_dir=str(local_dir),
172
+ token=HF_TOKEN,
173
  )
174
+ )
175
+ print(f"[download] ready: {repo_id}/{filename}")
176
+ return path
177
 
 
 
 
 
178
 
179
+ def download_assets():
180
+ checkpoint = _download("SulphurAI/Sulphur-2-base", "sulphur_distil_bf16.safetensors", ASSETS_DIR)
181
+ sulphur_lora = _download(
182
+ "SulphurAI/Sulphur-2-base",
183
+ "experimental/sulphur_experimental_lora_v1.safetensors",
184
+ ASSETS_DIR / "loras",
185
+ )
186
+ distilled_lora = _download(
187
+ "DeepBeepMeep/LTX-2",
188
+ "ltx-2.3-22b-distilled-lora-384.safetensors",
189
+ ASSETS_DIR / "loras",
190
+ )
191
+ upsampler = _download(
192
+ "DeepBeepMeep/LTX-2",
193
+ "ltx-2.3-spatial-upscaler-x2-1.1.safetensors",
194
+ ASSETS_DIR,
195
+ )
196
 
197
+ gemma_folder = "gemma-3-12b-it-qat-q4_0-unquantized"
198
+ gemma_files = [
199
+ f"{gemma_folder}/{gemma_folder}_quanto_bf16_int8.safetensors",
200
+ f"{gemma_folder}/added_tokens.json",
201
+ f"{gemma_folder}/chat_template.json",
202
+ f"{gemma_folder}/config_light.json",
203
+ f"{gemma_folder}/generation_config.json",
204
+ f"{gemma_folder}/preprocessor_config.json",
205
+ f"{gemma_folder}/processor_config.json",
206
+ f"{gemma_folder}/special_tokens_map.json",
207
+ f"{gemma_folder}/tokenizer.json",
208
+ f"{gemma_folder}/tokenizer.model",
209
+ f"{gemma_folder}/tokenizer_config.json",
210
+ ]
211
+ for filename in gemma_files:
212
+ _download("DeepBeepMeep/LTX-2", filename, ASSETS_DIR)
213
+
214
+ return {
215
+ "checkpoint": checkpoint,
216
+ "sulphur_lora": sulphur_lora,
217
+ "distilled_lora": distilled_lora,
218
+ "upsampler": upsampler,
219
+ "gemma_root": ASSETS_DIR / gemma_folder,
220
+ }
221
 
 
 
 
 
 
 
222
 
223
+ ASSETS = download_assets()
224
 
225
+ LORAS = [
226
+ LoraPathStrengthAndSDOps(str(ASSETS["distilled_lora"]), 1.0, LTXV_LORA_COMFY_RENAMING_MAP),
227
+ LoraPathStrengthAndSDOps(str(ASSETS["sulphur_lora"]), 0.5, LTXV_LORA_COMFY_RENAMING_MAP),
228
+ ]
229
+
230
+ pipeline = DistilledPipeline(
231
+ distilled_checkpoint_path=str(ASSETS["checkpoint"]),
232
+ spatial_upsampler_path=str(ASSETS["upsampler"]),
233
+ gemma_root=str(ASSETS["gemma_root"]),
234
+ loras=LORAS,
235
+ quantization=QuantizationPolicy.fp8_cast(),
236
+ )
237
+
238
+ print("[startup] Preloading LTX models")
239
+ ledger = pipeline.model_ledger
240
+ _transformer = ledger.transformer()
241
+ _video_encoder = ledger.video_encoder()
242
+ _video_decoder = ledger.video_decoder()
243
+ _audio_decoder = ledger.audio_decoder()
244
+ _vocoder = ledger.vocoder()
245
+ _spatial_upsampler = ledger.spatial_upsampler()
246
+ _text_encoder = ledger.text_encoder()
247
+ _embeddings_processor = ledger.gemma_embeddings_processor()
248
+
249
+ ledger.transformer = lambda: _transformer
250
+ ledger.video_encoder = lambda: _video_encoder
251
+ ledger.video_decoder = lambda: _video_decoder
252
+ ledger.audio_decoder = lambda: _audio_decoder
253
+ ledger.vocoder = lambda: _vocoder
254
+ ledger.spatial_upsampler = lambda: _spatial_upsampler
255
+ ledger.text_encoder = lambda: _text_encoder
256
+ ledger.gemma_embeddings_processor = lambda: _embeddings_processor
257
+ print("[startup] LTX pipeline ready")
258
+
259
+
260
+ def log_memory(tag):
261
+ if torch.cuda.is_available():
262
+ allocated = torch.cuda.memory_allocated() / 1024**3
263
+ peak = torch.cuda.max_memory_allocated() / 1024**3
264
+ free, total = torch.cuda.mem_get_info()
265
+ print(
266
+ f"[VRAM {tag}] allocated={allocated:.2f}GB peak={peak:.2f}GB "
267
+ f"free={free / 1024**3:.2f}GB total={total / 1024**3:.2f}GB"
268
+ )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
269
 
270
 
271
+ def detect_aspect_ratio(image):
272
+ if image is None:
273
+ return "16:9"
274
+ width, height = image.size
275
+ ratio = width / height
276
+ candidates = {"16:9": 16 / 9, "9:16": 9 / 16, "1:1": 1.0}
277
+ return min(candidates, key=lambda key: abs(ratio - candidates[key]))
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
278
 
 
 
279
 
280
+ def update_resolution(image, high_res):
281
+ aspect = detect_aspect_ratio(image)
282
+ tier = "high" if high_res else "low"
283
+ width, height = RESOLUTIONS[tier][aspect]
284
+ return gr.update(value=width), gr.update(value=height)
 
 
 
285
 
 
 
286
 
287
+ @spaces.GPU
288
+ @torch.inference_mode()
289
+ def generate_video(
290
+ input_image,
291
+ prompt,
292
+ duration,
293
+ enhance_prompt,
294
+ seed,
295
+ randomize_seed,
296
+ height,
297
+ width,
298
+ progress=gr.Progress(track_tqdm=True),
299
+ ):
300
+ current_seed = random.randint(0, MAX_SEED) if randomize_seed else int(seed)
301
  try:
302
+ if torch.cuda.is_available():
303
+ torch.cuda.reset_peak_memory_stats()
304
+ log_memory("start")
305
+
306
+ frame_rate = DEFAULT_FRAME_RATE
307
+ num_frames = int(float(duration) * frame_rate) + 1
308
+ num_frames = ((num_frames - 1 + 7) // 8) * 8 + 1
309
+ height = int(height)
310
+ width = int(width)
311
+
312
+ print(f"[generate] {width}x{height}, frames={num_frames}, seed={current_seed}")
313
+
314
+ images = []
315
+ if input_image is not None:
316
+ OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
317
+ input_path = OUTPUT_DIR / f"input_{current_seed}.png"
318
+ input_image.save(input_path)
319
+ images = [ImageConditioningInput(path=str(input_path), frame_idx=0, strength=1.0)]
320
+
321
+ tiling_config = TilingConfig.default()
322
+ chunks = get_video_chunks_number(num_frames, tiling_config)
323
+ log_memory("before pipeline")
324
+
325
+ video, audio = pipeline(
326
+ prompt=prompt,
327
+ seed=current_seed,
328
+ height=height,
329
+ width=width,
330
+ num_frames=num_frames,
331
+ frame_rate=frame_rate,
332
+ images=images,
333
+ tiling_config=tiling_config,
334
+ enhance_prompt=bool(enhance_prompt),
335
+ )
336
+
337
+ output_path = tempfile.mktemp(suffix=".mp4")
338
+ encode_video(video=video, fps=frame_rate, audio=audio, output_path=output_path, video_chunks_number=chunks)
339
+ log_memory("after encode")
340
+ return output_path, current_seed
 
 
341
  except Exception as exc:
342
+ import traceback
343
+
344
+ log_memory("error")
345
+ print(f"[ERROR] {exc}\n{traceback.format_exc()}")
346
+ raise gr.Error(str(exc))
347
+
348
+
349
+ with gr.Blocks(title="Sulphur LTX Image to Video") as demo:
350
+ gr.Markdown("# Sulphur Image to Video")
351
+
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
352
  with gr.Row():
353
+ with gr.Column():
354
+ input_image = gr.Image(label="Input Image", type="pil")
355
+ prompt = gr.Textbox(
356
+ label="Prompt",
357
+ value=DEFAULT_PROMPT,
358
+ lines=3,
359
+ placeholder="Describe the motion and animation...",
360
+ )
361
+ with gr.Row():
362
+ duration = gr.Slider(1.0, 5.0, value=3.0, step=0.1, label="Duration")
363
+ with gr.Column():
364
+ enhance_prompt = gr.Checkbox(label="Enhance Prompt", value=False)
365
+ high_res = gr.Checkbox(label="High Resolution", value=False)
366
+
367
+ generate_btn = gr.Button("Generate", variant="primary")
368
+
369
  with gr.Accordion("Advanced", open=False):
370
+ seed = gr.Slider(0, MAX_SEED, value=10, step=1, label="Seed")
371
+ randomize_seed = gr.Checkbox(label="Randomize Seed", value=True)
372
+ with gr.Row():
373
+ width = gr.Number(label="Width", value=768, precision=0)
374
+ height = gr.Number(label="Height", value=512, precision=0)
 
 
 
 
 
 
 
 
 
 
375
 
376
+ with gr.Column():
377
+ output_video = gr.Video(label="Output Video", autoplay=True)
378
+
379
+ input_image.change(fn=update_resolution, inputs=[input_image, high_res], outputs=[width, height])
380
+ high_res.change(fn=update_resolution, inputs=[input_image, high_res], outputs=[width, height])
381
+
382
+ generate_btn.click(
383
  fn=generate_video,
384
+ inputs=[input_image, prompt, duration, enhance_prompt, seed, randomize_seed, height, width],
385
+ outputs=[output_video, seed],
386
  )
387
 
388
+
389
  if __name__ == "__main__":
390
+ demo.launch(theme=gr.themes.Citrus())
requirements.txt CHANGED
@@ -1,74 +1,16 @@
1
- # HF Spaces runtime
2
- gradio>=6.0.0
3
  spaces
4
  huggingface_hub>=0.24.0
5
 
6
- # Wan2GP deps (from requirements.txt — all packages that install on Linux)
7
- mmgp==3.7.6
8
- diffusers==0.36.0
9
- transformers==4.54.0
10
- tokenizers>=0.20.3
11
- accelerate>=1.1.1
 
12
  tqdm
 
13
  imageio
14
  imageio-ffmpeg
15
- einops
16
  sentencepiece
17
- open_clip_torch>=2.29.0
18
- numpy==2.1.2
19
- num2words==0.5.14
20
- moviepy==1.0.3
21
- av
22
- ffmpeg-python
23
- pygame>=2.1.0
24
- sounddevice>=0.4.0
25
- soundfile
26
- mutagen
27
- pyloudnorm
28
- librosa==0.11.0
29
- speechbrain==1.0.3
30
- openai-whisper==20250625
31
- audio-separator==0.36.1
32
- pyannote.audio==3.3.2
33
- loguru
34
- dashscope
35
- s3tokenizer
36
- conformer==0.3.2
37
- spacy_pkuseg
38
- spacy
39
- opencv-python-headless>=4.12.0.88
40
- pycocotools
41
- segment-anything
42
- rembg==2.0.65
43
- onnxruntime>=1.20.0
44
- decord
45
- timm
46
- iopath>=0.1.10
47
- insightface==0.7.3
48
- facexlib==0.3.0
49
- vector_quantize_pytorch==1.27.19
50
- omegaconf
51
- hydra-core
52
- easydict
53
- torchdiffeq>=0.2.5
54
- tensordict>=0.6.1
55
- peft==0.17.0
56
- vector-quantize-pytorch
57
- matplotlib
58
- gguf==0.17.1
59
- flash-linear-attention==0.4.1
60
- ftfy
61
- piexif
62
- nvidia-ml-py
63
- misaki
64
- gitdb==4.0.12
65
- gitpython==3.1.45
66
- stringzilla==4.0.14
67
- xxhash
68
- munch
69
- wetext==0.1.2
70
- markdown
71
- Pillow
72
- safetensors
73
- https://github.com/deepbeepmeep/smplfitter/releases/download/v0.2.10/smplfitter-0.2.10-py3-none-any.whl
74
- https://github.com/deepbeepmeep/chumpy/releases/download/v0.71/chumpy-0.71-py3-none-any.whl
 
1
+ gradio>=5.29.0
 
2
  spaces
3
  huggingface_hub>=0.24.0
4
 
5
+ einops
6
+ numpy
7
+ transformers>=4.52
8
+ safetensors
9
+ accelerate
10
+ scipy>=1.14
11
+ av
12
  tqdm
13
+ Pillow
14
  imageio
15
  imageio-ffmpeg
 
16
  sentencepiece