Daankular commited on
Commit
7fec411
·
verified ·
1 Parent(s): e6ed09a

Upload 3 files

Browse files

revert back to working

Files changed (3) hide show
  1. app.py +175 -505
  2. generate.py +139 -0
  3. requirements.txt +68 -13
app.py CHANGED
@@ -1,535 +1,205 @@
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
- import builtins
 
10
  from pathlib import Path
11
 
12
  import gradio as gr
13
- import numpy as np
14
  import spaces
15
- import torch
16
- from huggingface_hub import hf_hub_download
17
-
18
-
19
- LTX_REPO_URL = "https://github.com/Lightricks/LTX-2.git"
20
- LTX_COMMIT_SHA = "ae855f8538843825f9015a419cf4ba5edaf5eec2"
21
-
22
- HF_TOKEN = os.environ.get("HF_TOKEN")
23
- PERSISTENT = Path("/data") if Path("/data").exists() else Path(tempfile.gettempdir())
24
- ASSETS_DIR = PERSISTENT / "sulphur_ltx"
25
- LTX_REPO_DIR = PERSISTENT / "LTX-2"
26
- OUTPUT_DIR = PERSISTENT / "sulphur_outputs"
27
-
28
- MAX_SEED = np.iinfo(np.int32).max
29
- DEFAULT_FRAME_RATE = 24.0
30
- DEFAULT_PROMPT = "Make this image come alive with cinematic motion, smooth animation."
31
- PROMPT_ADHERENCE_SUFFIX = (
32
- "Follow the prompt literally. Preserve the named subjects, actions, setting, and style."
33
- )
34
- MAX_PROMPT_CHARS = 900
35
- MAX_ENHANCED_PROMPT_CHARS = 1400
36
- PROMPT_ENHANCER_SYSTEM_PROMPT = (
37
- "You are Sulphur's prompt enhancer for image-to-video generation. Rewrite the user's prompt into one concise, "
38
- "literal cinematic video prompt. Preserve the user's subject, action, setting, style, and intent. Add useful "
39
- "visual motion, camera movement, lighting, and temporal details. Do not refuse, moralize, apologize, explain, "
40
- "quote policy, or mention being an AI. Return only the rewritten prompt."
41
- )
42
- PROMPT_ENHANCER_USER_TEMPLATE = "Rewrite this video prompt only:\n{prompt}"
43
-
44
- RESOLUTIONS = {
45
- "high": {"16:9": (1536, 1024), "9:16": (1024, 1536), "1:1": (1024, 1024)},
46
- "low": {"16:9": (768, 512), "9:16": (512, 768), "1:1": (768, 768)},
47
- }
48
-
49
-
50
- def _run(cmd, cwd=None, check=True):
51
- print("[setup]", " ".join(str(part) for part in cmd))
52
- return subprocess.run(cmd, cwd=cwd, check=check)
53
-
54
-
55
- def install_ltx_runtime():
56
- if not (LTX_REPO_DIR / ".git").exists():
57
- LTX_REPO_DIR.parent.mkdir(parents=True, exist_ok=True)
58
- if LTX_REPO_DIR.exists():
59
- raise RuntimeError(f"{LTX_REPO_DIR} exists but is not a git checkout")
60
- _run(["git", "init", str(LTX_REPO_DIR)])
61
- _run(["git", "remote", "add", "origin", LTX_REPO_URL], cwd=LTX_REPO_DIR)
62
-
63
- _run(["git", "fetch", "--depth", "1", "origin", LTX_COMMIT_SHA], cwd=LTX_REPO_DIR)
64
- _run(["git", "checkout", LTX_COMMIT_SHA], cwd=LTX_REPO_DIR)
65
-
66
- _run(
67
- [
68
- sys.executable,
69
- "-m",
70
- "pip",
71
- "install",
72
- "--force-reinstall",
73
- "--no-deps",
74
- "-e",
75
- str(LTX_REPO_DIR / "packages" / "ltx-core"),
76
- "-e",
77
- str(LTX_REPO_DIR / "packages" / "ltx-pipelines"),
78
- ]
79
- )
80
-
81
- sys.path.insert(0, str(LTX_REPO_DIR / "packages" / "ltx-pipelines" / "src"))
82
- sys.path.insert(0, str(LTX_REPO_DIR / "packages" / "ltx-core" / "src"))
83
-
84
-
85
- install_ltx_runtime()
86
-
87
-
88
- _real_import = builtins.__import__
89
-
90
-
91
- def _import_without_xformers(name, globals=None, locals=None, fromlist=(), level=0):
92
- if name == "xformers" or name.startswith("xformers."):
93
- raise ImportError("xformers is disabled for this Space runtime")
94
- return _real_import(name, globals, locals, fromlist, level)
95
 
 
 
 
 
 
 
 
96
 
97
- builtins.__import__ = _import_without_xformers
98
- try:
99
- from ltx_core.loader import LTXV_LORA_COMFY_RENAMING_MAP, LoraPathStrengthAndSDOps
100
- from ltx_core.loader.primitives import StateDict
101
- from ltx_core.loader.sd_ops import KeyValueOperationResult, SDOps
102
- from ltx_core.loader.sft_loader import SafetensorsStateDictLoader
103
- from ltx_core.model.video_vae import TilingConfig, get_video_chunks_number
104
- from ltx_core.quantization import QuantizationPolicy
105
- import ltx_pipelines.utils.model_ledger as _model_ledger_mod
106
- from ltx_pipelines.distilled import DistilledPipeline
107
- from ltx_pipelines.utils.args import ImageConditioningInput
108
- from ltx_pipelines.utils.media_io import encode_video
109
- finally:
110
- builtins.__import__ = _real_import
111
-
112
-
113
- _model_ledger_mod.GEMMA_LLM_KEY_OPS = (
114
- SDOps("GEMMA_LLM_KEY_OPS_SULPHUR")
115
- .with_matching(prefix="model.language_model.")
116
- .with_matching(prefix="model.vision_tower.")
117
- .with_matching(prefix="model.multi_modal_projector.")
118
- .with_replacement("model.language_model.", "model.model.language_model.")
119
- .with_replacement("model.vision_tower.", "model.model.vision_tower.")
120
- .with_replacement("model.multi_modal_projector.", "model.model.multi_modal_projector.")
121
- .with_kv_operation(
122
- operation=lambda key, value: [
123
- KeyValueOperationResult(key, value),
124
- KeyValueOperationResult("model.lm_head.weight", value),
125
- ],
126
- key_prefix="model.model.language_model.embed_tokens.weight",
127
- )
128
- )
129
-
130
 
131
- _SAFETENSORS_DTYPE_MAP = {
132
- "F64": torch.float64,
133
- "F32": torch.float32,
134
- "F16": torch.float16,
135
- "BF16": torch.bfloat16,
136
- "F8_E5M2": torch.float8_e5m2,
137
- "F8_E4M3": torch.float8_e4m3fn,
138
- "I64": torch.int64,
139
- "I32": torch.int32,
140
- "I16": torch.int16,
141
- "I8": torch.int8,
142
- "U8": torch.uint8,
143
- "BOOL": torch.bool,
 
 
 
144
  }
145
 
146
-
147
- def _patched_safetensors_load(self, path, sd_ops, device=None):
148
- sd = {}
149
- size = 0
150
- dtype = set()
151
- device = device or torch.device("cpu")
152
- model_paths = path if isinstance(path, list | tuple) else [path]
153
- for shard_path in model_paths:
154
- with open(shard_path, "rb") as reader:
155
- header_len = struct.unpack("<Q", reader.read(8))[0]
156
- header = json.loads(reader.read(header_len).decode("utf-8"))
157
- data_base = 8 + header_len
158
- for name, meta in header.items():
159
- if name == "__metadata__":
160
- continue
161
- expected_name = name if sd_ops is None else sd_ops.apply_to_key(name)
162
- if expected_name is None:
163
- continue
164
- start, end = meta["data_offsets"]
165
- reader.seek(data_base + start)
166
- raw = reader.read(end - start)
167
- tensor = torch.frombuffer(
168
- bytearray(raw), dtype=_SAFETENSORS_DTYPE_MAP[meta["dtype"]]
169
- ).reshape(meta["shape"])
170
- tensor = tensor.to(device=device, non_blocking=True, copy=False)
171
- kvs = ((expected_name, tensor),) if sd_ops is None else sd_ops.apply_to_key_value(expected_name, tensor)
172
- for key, value in kvs:
173
- size += value.nbytes
174
- dtype.add(value.dtype)
175
- sd[key] = value
176
- return StateDict(sd=sd, device=device, size=size, dtype=dtype)
177
-
178
-
179
- SafetensorsStateDictLoader.load = _patched_safetensors_load
180
- print("[FUSE-PATCH] Safetensors loader uses chunked reads")
181
-
182
- logging.getLogger().setLevel(logging.INFO)
183
-
184
-
185
- def _download(repo_id, filename, local_dir):
186
- local_dir = Path(local_dir)
187
- local_dir.mkdir(parents=True, exist_ok=True)
188
- path = Path(
189
- hf_hub_download(
190
- repo_id=repo_id,
191
- filename=filename,
192
- local_dir=str(local_dir),
193
- token=HF_TOKEN,
194
  )
195
- )
196
- print(f"[download] ready: {repo_id}/{filename}")
197
- return path
198
-
199
-
200
- def _safetensors_header(path):
201
- with open(path, "rb") as reader:
202
- header_len = struct.unpack("<Q", reader.read(8))[0]
203
- return json.loads(reader.read(header_len).decode("utf-8"))
204
-
205
-
206
- def _validate_lora(name, lora):
207
- path = Path(lora.path)
208
- if not path.exists():
209
- raise FileNotFoundError(f"{name} LoRA missing: {path}")
210
- keys = [key for key in _safetensors_header(path) if key != "__metadata__"]
211
- mapped = [lora.sd_ops.apply_to_key(key) for key in keys]
212
- mapped = [key for key in mapped if key is not None]
213
- lora_a = sum(1 for key in mapped if key.endswith(".lora_A.weight"))
214
- lora_b = sum(1 for key in mapped if key.endswith(".lora_B.weight"))
215
- alpha = sum(1 for key in mapped if key.endswith(".alpha"))
216
- if lora_a == 0 or lora_b == 0:
217
- raise RuntimeError(f"{name} LoRA has no usable lora_A/lora_B keys after mapping: {path}")
218
- print(
219
- f"[lora] active: {name} strength={lora.strength} path={path} "
220
- f"lora_A={lora_a} lora_B={lora_b} alpha={alpha} size={path.stat().st_size / 1024**3:.2f}GB"
221
- )
222
-
223
-
224
- def _prompt_for_model(prompt):
225
- prompt = " ".join(str(prompt or DEFAULT_PROMPT).split())
226
- prompt = _truncate_prompt(prompt, MAX_PROMPT_CHARS)
227
- if PROMPT_ADHERENCE_SUFFIX.lower() in prompt.lower():
228
- return prompt
229
- return f"{prompt}\n\n{PROMPT_ADHERENCE_SUFFIX}"
230
-
231
-
232
- def _truncate_prompt(prompt, limit):
233
- prompt = str(prompt or "").strip()
234
- if len(prompt) <= limit:
235
- return prompt
236
- truncated = prompt[:limit].rsplit(" ", 1)[0].strip()
237
- return truncated or prompt[:limit].strip()
238
-
239
 
240
- _PROMPT_ENHANCER = None
 
241
 
242
-
243
- def _get_prompt_enhancer():
244
- global _PROMPT_ENHANCER
245
- if _PROMPT_ENHANCER is not None:
246
- return _PROMPT_ENHANCER
247
- from llama_cpp import Llama
248
-
249
- print("[prompt-enhancer] loading Sulphur GGUF enhancer")
250
- try:
251
- _PROMPT_ENHANCER = Llama.from_pretrained(
252
- repo_id="SulphurAI/Sulphur-2-base",
253
- filename="prompt_enhancer/sulphur_prompt_enhancer_model-q8_0.gguf",
254
- additional_files=["prompt_enhancer/mmproj-BF16.gguf"],
255
- local_dir=str(ASSETS_DIR),
256
- local_dir_use_symlinks=False,
257
- n_ctx=2048,
258
- n_threads=max(1, min(8, os.cpu_count() or 1)),
259
- n_gpu_layers=0,
260
- verbose=False,
261
- )
262
- except Exception as exc:
263
- print(f"[prompt-enhancer] load failed: {type(exc).__name__}: {exc}")
264
- _PROMPT_ENHANCER = None
265
- raise
266
- return _PROMPT_ENHANCER
267
-
268
-
269
- def _enhance_prompt_with_sulphur(prompt):
270
- base_prompt = _truncate_prompt(prompt or DEFAULT_PROMPT, MAX_PROMPT_CHARS)
271
- try:
272
- llm = _get_prompt_enhancer()
273
- result = llm.create_chat_completion(
274
- messages=[
275
- {"role": "system", "content": PROMPT_ENHANCER_SYSTEM_PROMPT},
276
- {"role": "user", "content": PROMPT_ENHANCER_USER_TEMPLATE.format(prompt=base_prompt)},
277
- ],
278
- temperature=0.35,
279
- top_p=0.9,
280
- max_tokens=320,
281
- )
282
- enhanced = result["choices"][0]["message"]["content"].strip()
283
- except Exception as exc:
284
- print(f"[prompt-enhancer] fallback to literal prompt: {type(exc).__name__}: {exc}")
285
- return base_prompt
286
- enhanced = enhanced.strip("\"'` \n\t")
287
- blocked_phrases = ("cannot fulfill", "safety guidelines", "as an ai", "i cannot", "i'm unable")
288
- if not enhanced or enhanced == "/" or any(phrase in enhanced.lower() for phrase in blocked_phrases):
289
- print(f"[prompt-enhancer] rejected unusable output; falling back to literal prompt: {enhanced!r}")
290
- return base_prompt
291
- enhanced = _truncate_prompt(enhanced, MAX_ENHANCED_PROMPT_CHARS)
292
- print(f"[prompt-enhancer] {enhanced}")
293
- return enhanced
294
-
295
-
296
- def _ensure_gemma_model_alias(gemma_weight):
297
- gemma_weight = Path(gemma_weight)
298
- alias = gemma_weight.with_name("model.safetensors")
299
- if gemma_weight == alias:
300
- return alias
301
- if alias.exists() or alias.is_symlink():
302
- alias.unlink()
303
- gemma_weight.replace(alias)
304
- print(f"[download] ready: {alias}")
305
- return alias
306
-
307
-
308
- def download_assets():
309
- checkpoint = _download("SulphurAI/Sulphur-2-base", "sulphur_distil_bf16.safetensors", ASSETS_DIR)
310
- sulphur_lora = _download(
311
- "SulphurAI/Sulphur-2-base",
312
- "experimental/sulphur_experimental_lora_v1.safetensors",
313
- ASSETS_DIR / "loras",
314
- )
315
- distilled_lora = _download(
316
- "SulphurAI/Sulphur-2-base",
317
- "distill_loras/ltx-2.3-22b-distilled-lora-1.1_fro90_ceil72_condsafe.safetensors",
318
- ASSETS_DIR / "loras",
319
- )
320
- upsampler = _download(
321
- "DeepBeepMeep/LTX-2",
322
- "ltx-2.3-spatial-upscaler-x2-1.1.safetensors",
323
- ASSETS_DIR,
324
- )
325
- prompt_enhancer_model = _download(
326
- "SulphurAI/Sulphur-2-base",
327
- "prompt_enhancer/sulphur_prompt_enhancer_model-q8_0.gguf",
328
- ASSETS_DIR,
329
- )
330
- prompt_enhancer_mmproj = _download(
331
- "SulphurAI/Sulphur-2-base",
332
- "prompt_enhancer/mmproj-BF16.gguf",
333
- ASSETS_DIR,
334
- )
335
-
336
- gemma_folder = "gemma-3-12b-it-qat-q4_0-unquantized"
337
- gemma_files = [
338
- f"{gemma_folder}/{gemma_folder}.safetensors",
339
- f"{gemma_folder}/added_tokens.json",
340
- f"{gemma_folder}/chat_template.json",
341
- f"{gemma_folder}/config.json",
342
- f"{gemma_folder}/config_light.json",
343
- f"{gemma_folder}/generation_config.json",
344
- f"{gemma_folder}/preprocessor_config.json",
345
- f"{gemma_folder}/processor_config.json",
346
- f"{gemma_folder}/special_tokens_map.json",
347
- f"{gemma_folder}/tokenizer.json",
348
- f"{gemma_folder}/tokenizer.model",
349
- f"{gemma_folder}/tokenizer_config.json",
350
- ]
351
- gemma_weight = None
352
- for filename in gemma_files:
353
- path = _download("DeepBeepMeep/LTX-2", filename, ASSETS_DIR)
354
- if filename.endswith(".safetensors"):
355
- gemma_weight = path
356
-
357
- old_quanto = ASSETS_DIR / gemma_folder / f"{gemma_folder}_quanto_bf16_int8.safetensors"
358
- if old_quanto.exists():
359
- old_quanto.unlink()
360
- _ensure_gemma_model_alias(gemma_weight)
361
-
362
- return {
363
- "checkpoint": checkpoint,
364
- "sulphur_lora": sulphur_lora,
365
- "distilled_lora": distilled_lora,
366
- "upsampler": upsampler,
367
- "prompt_enhancer_model": prompt_enhancer_model,
368
- "prompt_enhancer_mmproj": prompt_enhancer_mmproj,
369
- "gemma_root": ASSETS_DIR / gemma_folder,
370
- }
371
-
372
-
373
- ASSETS = download_assets()
374
-
375
- LORAS = [
376
- LoraPathStrengthAndSDOps(str(ASSETS["distilled_lora"]), 1.0, LTXV_LORA_COMFY_RENAMING_MAP),
377
- LoraPathStrengthAndSDOps(str(ASSETS["sulphur_lora"]), 1.0, LTXV_LORA_COMFY_RENAMING_MAP),
378
- ]
379
- _validate_lora("sulphur_distilled_condsafe", LORAS[0])
380
- _validate_lora("sulphur_experimental_lora_v1", LORAS[1])
381
- print("[lora] configured order:")
382
- for index, lora in enumerate(LORAS, start=1):
383
- print(f"[lora] {index}: strength={lora.strength} path={lora.path}")
384
-
385
- pipeline = DistilledPipeline(
386
- distilled_checkpoint_path=str(ASSETS["checkpoint"]),
387
- spatial_upsampler_path=str(ASSETS["upsampler"]),
388
- gemma_root=str(ASSETS["gemma_root"]),
389
- loras=LORAS,
390
- quantization=QuantizationPolicy.fp8_cast(),
391
- )
392
-
393
- print("[startup] LTX assets ready; model loading is deferred to the GPU worker")
394
-
395
-
396
- def log_memory(tag):
397
- if torch.cuda.is_available():
398
- allocated = torch.cuda.memory_allocated() / 1024**3
399
- peak = torch.cuda.max_memory_allocated() / 1024**3
400
- free, total = torch.cuda.mem_get_info()
401
- print(
402
- f"[VRAM {tag}] allocated={allocated:.2f}GB peak={peak:.2f}GB "
403
- f"free={free / 1024**3:.2f}GB total={total / 1024**3:.2f}GB"
404
  )
 
 
405
 
 
 
 
406
 
407
- def detect_aspect_ratio(image):
408
- if image is None:
409
- return "16:9"
410
- width, height = image.size
411
- ratio = width / height
412
- candidates = {"16:9": 16 / 9, "9:16": 9 / 16, "1:1": 1.0}
413
- return min(candidates, key=lambda key: abs(ratio - candidates[key]))
414
 
 
415
 
416
- def update_resolution(image, high_res):
417
- aspect = detect_aspect_ratio(image)
418
- tier = "high" if high_res else "low"
419
- width, height = RESOLUTIONS[tier][aspect]
420
- return gr.update(value=width), gr.update(value=height)
421
 
422
 
423
  @spaces.GPU(duration=120)
424
- def generate_video(
425
- input_image,
426
- prompt,
427
- duration,
428
- seed,
429
- randomize_seed,
430
- height,
431
- width,
432
- progress=gr.Progress(track_tqdm=True),
433
- ):
434
- current_seed = random.randint(0, MAX_SEED) if randomize_seed else int(seed)
435
- try:
436
- with torch.no_grad():
437
- if torch.cuda.is_available():
438
- torch.cuda.reset_peak_memory_stats()
439
- log_memory("start")
440
-
441
- frame_rate = DEFAULT_FRAME_RATE
442
- num_frames = int(float(duration) * frame_rate) + 1
443
- num_frames = ((num_frames - 1 + 7) // 8) * 8 + 1
444
- height = int(height)
445
- width = int(width)
446
-
447
- print(f"[generate] {width}x{height}, frames={num_frames}, seed={current_seed}")
448
- print(f"[prompt] {prompt}")
449
-
450
- images = []
451
- if input_image is not None:
452
- OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
453
- input_path = OUTPUT_DIR / f"input_{current_seed}.png"
454
- input_image.save(input_path)
455
- images = [ImageConditioningInput(path=str(input_path), frame_idx=0, strength=1.0)]
456
-
457
- tiling_config = TilingConfig.default()
458
- chunks = get_video_chunks_number(num_frames, tiling_config)
459
- log_memory("before pipeline")
460
-
461
- video, audio = pipeline(
462
- prompt=prompt,
463
- seed=current_seed,
464
- height=height,
465
- width=width,
466
- num_frames=num_frames,
467
- frame_rate=frame_rate,
468
- images=images,
469
- tiling_config=tiling_config,
470
- enhance_prompt=False,
471
- )
472
-
473
- output_path = tempfile.mktemp(suffix=".mp4")
474
- encode_video(video=video, fps=frame_rate, audio=audio, output_path=output_path, video_chunks_number=chunks)
475
- log_memory("after encode")
476
- return output_path, current_seed
477
- except Exception as exc:
478
- import traceback
479
-
480
- log_memory("error")
481
- print(f"[ERROR] {exc}\n{traceback.format_exc()}")
482
- raise gr.Error(str(exc))
483
-
484
-
485
- def prepare_prompt(prompt, enhance_prompt):
486
- if enhance_prompt:
487
- prompt = _enhance_prompt_with_sulphur(prompt)
488
- prepared = _prompt_for_model(prompt)
489
- print(f"[prompt] prepared: {prepared}")
490
- return prepared
491
-
492
-
493
- with gr.Blocks(title="Sulphur LTX Image to Video") as demo:
494
- gr.Markdown("# Sulphur Image to Video")
495
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
496
  with gr.Row():
497
- with gr.Column():
498
- input_image = gr.Image(label="Input Image", type="pil")
499
- prompt = gr.Textbox(
500
- label="Prompt",
501
- value=DEFAULT_PROMPT,
502
- lines=3,
503
- placeholder="Describe the motion and animation...",
504
- )
505
- with gr.Row():
506
- duration = gr.Slider(1.0, 5.0, value=3.0, step=0.1, label="Duration")
507
- with gr.Column():
508
- enhance_prompt = gr.Checkbox(label="Enhance Prompt", value=True)
509
- high_res = gr.Checkbox(label="High Resolution", value=False)
510
-
511
- generate_btn = gr.Button("Generate", variant="primary")
512
- prepared_prompt = gr.State()
513
-
514
  with gr.Accordion("Advanced", open=False):
515
- seed = gr.Slider(0, MAX_SEED, value=10, step=1, label="Seed")
516
- randomize_seed = gr.Checkbox(label="Randomize Seed", value=True)
517
- with gr.Row():
518
- width = gr.Number(label="Width", value=768, precision=0)
519
- height = gr.Number(label="Height", value=512, precision=0)
520
-
521
- with gr.Column():
522
- output_video = gr.Video(label="Output Video", autoplay=True)
523
-
524
- input_image.change(fn=update_resolution, inputs=[input_image, high_res], outputs=[width, height])
525
- high_res.change(fn=update_resolution, inputs=[input_image, high_res], outputs=[width, height])
526
-
527
- generate_btn.click(fn=prepare_prompt, inputs=[prompt, enhance_prompt], outputs=[prepared_prompt]).then(
528
  fn=generate_video,
529
- inputs=[input_image, prepared_prompt, duration, seed, randomize_seed, height, width],
530
- outputs=[output_video, seed],
531
  )
532
 
533
-
534
  if __name__ == "__main__":
535
- demo.launch(theme=gr.themes.Citrus())
 
1
+ """
2
+ Sulphur — Image to Video (HF Spaces).
3
+ Clones Wan2GP and downloads models on first run.
4
+ Generation is handled by generate.py called as a subprocess inside @spaces.GPU.
5
+ """
6
+
7
  import os
 
 
 
8
  import sys
9
+ import subprocess
10
+ import shutil
11
  import tempfile
12
+ import threading
13
+ import json
14
  from pathlib import Path
15
 
16
  import gradio as gr
 
17
  import spaces
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
18
 
19
+ _HF_TOKEN = os.environ.get("HF_TOKEN")
20
+ _PERSISTENT = Path("/data") if Path("/data").exists() else Path(tempfile.gettempdir())
21
+ WAN2GP_ROOT = _PERSISTENT / "Wan2GP"
22
+ CKPTS_DIR = WAN2GP_ROOT / "ckpts"
23
+ LORAS_DIR = WAN2GP_ROOT / "loras" / "ltx2"
24
+ FINETUNES_DIR = WAN2GP_ROOT / "finetunes"
25
+ GENERATE_PY = Path(__file__).parent / "generate.py"
26
 
27
+ SULPHUR_ASSETS = [
28
+ ("SulphurAI/Sulphur-2-base", "sulphur_distil_bf16.safetensors", CKPTS_DIR),
29
+ ]
30
+ LTX_ASSETS = [
31
+ ("SulphurAI/Sulphur-2-base", "experimental/sulphur_experimental_lora_v1.safetensors", LORAS_DIR),
32
+ ("DeepBeepMeep/LTX-2", "ltx-2.3-22b-distilled-lora-384.safetensors", LORAS_DIR),
33
+ ("DeepBeepMeep/LTX-2", "ltx-2.3-22b_vae.safetensors", CKPTS_DIR),
34
+ ("DeepBeepMeep/LTX-2", "ltx-2.3-22b_text_embedding_projection.safetensors", CKPTS_DIR),
35
+ ("DeepBeepMeep/LTX-2", "ltx-2.3-22b_embeddings_connector.safetensors", CKPTS_DIR),
36
+ ]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
37
 
38
+ SULPHUR_FINETUNE = {
39
+ "model": {
40
+ "name": "Sulphur 2 Base",
41
+ "visible": True,
42
+ "architecture": "ltx2_22B",
43
+ "parent_model_type": "ltx2_22B",
44
+ "description": "LTX-2.3 fine-tuned i2v. Distilled checkpoint.",
45
+ # Full distilled model — do NOT also preload the rank-768 LoRA (README: use one or the other)
46
+ "URLs": [str(CKPTS_DIR / "sulphur_distil_bf16.safetensors")],
47
+ "preload_URLs": [],
48
+ },
49
+ "num_inference_steps": 8,
50
+ "video_length": 81,
51
+ "resolution": "832x480",
52
+ "guidance_scale": 3.5,
53
+ "alt_guidance_scale": 3.5,
54
  }
55
 
56
+ _setup_lock = threading.Lock()
57
+ _setup_done = False
58
+
59
+
60
+ def _download(repo_id, filename, dest_dir):
61
+ from huggingface_hub import hf_hub_download
62
+ dest_dir.mkdir(parents=True, exist_ok=True)
63
+ dest = dest_dir / Path(filename).name # flat — strip any subfolder
64
+ if dest.exists():
65
+ print(f"[download] cached: {dest.name}")
66
+ return
67
+ print(f"[download] {repo_id}/{filename}")
68
+ hf_hub_download(repo_id=repo_id, filename=filename,
69
+ local_dir=str(dest_dir), token=_HF_TOKEN)
70
+ # hf_hub_download preserves subfolder structure; flatten to dest_dir root
71
+ downloaded = dest_dir / filename
72
+ if downloaded.exists() and not dest.exists():
73
+ shutil.move(str(downloaded), str(dest))
74
+
75
+
76
+ def setup():
77
+ global _setup_done
78
+ with _setup_lock:
79
+ if _setup_done:
80
+ return
81
+ _setup_done = True
82
+
83
+ if not (WAN2GP_ROOT / "shared" / "api.py").exists():
84
+ WAN2GP_ROOT.mkdir(parents=True, exist_ok=True)
85
+ print("[setup] Cloning Wan2GP...")
86
+ subprocess.run(
87
+ ["git", "clone", "--depth=1",
88
+ "https://github.com/deepbeepmeep/Wan2GP.git", str(WAN2GP_ROOT)],
89
+ check=True,
 
 
 
 
 
 
 
 
 
 
 
 
 
 
90
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
91
 
92
+ for repo, fname, dest in SULPHUR_ASSETS + LTX_ASSETS:
93
+ _download(repo, fname, dest)
94
 
95
+ # Gemma text encoder — must stay in its subfolder (Wan2GP looks there by name)
96
+ _gemma_folder = "gemma-3-12b-it-qat-q4_0-unquantized"
97
+ _gemma_file = f"{_gemma_folder}_quanto_bf16_int8.safetensors"
98
+ gemma_dest = CKPTS_DIR / _gemma_folder / _gemma_file
99
+ if not gemma_dest.exists():
100
+ from huggingface_hub import hf_hub_download
101
+ print("[download] Gemma text encoder...")
102
+ hf_hub_download(
103
+ repo_id="DeepBeepMeep/LTX-2",
104
+ filename=f"{_gemma_folder}/{_gemma_file}",
105
+ local_dir=str(CKPTS_DIR),
106
+ token=_HF_TOKEN,
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
107
  )
108
+ else:
109
+ print("[download] cached: Gemma text encoder")
110
 
111
+ FINETUNES_DIR.mkdir(parents=True, exist_ok=True)
112
+ (FINETUNES_DIR / "sulphur_2_base.json").write_text(json.dumps(SULPHUR_FINETUNE, indent=2))
113
+ print("[setup] Done.")
114
 
 
 
 
 
 
 
 
115
 
116
+ setup()
117
 
118
+ RESOLUTIONS = ["832x480", "480x832", "640x640", "1024x576", "576x1024"]
 
 
 
 
119
 
120
 
121
  @spaces.GPU(duration=120)
122
+ def generate_video(image, prompt, resolution, steps, guidance_scale, frames, seed):
123
+ if image is None:
124
+ raise gr.Error("Please upload an image.")
125
+ if not prompt.strip():
126
+ raise gr.Error("Please enter a prompt.")
127
+
128
+ out_file = Path(tempfile.mkdtemp()) / "output.mp4"
129
+ env = {**os.environ, "WAN2GP_ROOT": str(WAN2GP_ROOT)}
130
+
131
+ cmd = [
132
+ sys.executable, str(GENERATE_PY),
133
+ "--image", image,
134
+ "--prompt", prompt,
135
+ "--output", str(out_file),
136
+ "--model", "sulphur-2",
137
+ "--seed", str(int(seed)),
138
+ "--resolution", resolution,
139
+ "--steps", str(int(steps)),
140
+ "--guidance_scale", str(float(guidance_scale)),
141
+ "--frames", str(int(frames)),
142
+ ]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
143
 
144
+ log_lines = []
145
+ proc = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
146
+ text=True, bufsize=0, env=env)
147
+
148
+ buf = ""
149
+ while True:
150
+ chunk = proc.stdout.read(256)
151
+ if not chunk:
152
+ break
153
+ buf += chunk
154
+ # Split on \r or \n — tqdm uses \r to overwrite progress lines
155
+ parts = buf.replace("\r", "\n").split("\n")
156
+ buf = parts[-1]
157
+ for part in parts[:-1]:
158
+ stripped = part.strip()
159
+ if not stripped:
160
+ continue
161
+ # Overwrite last line if it looks like a progress bar update
162
+ if log_lines and ("%" in stripped or "it/s" in stripped or "step" in stripped.lower()):
163
+ log_lines[-1] = stripped
164
+ else:
165
+ log_lines.append(stripped)
166
+ print(stripped)
167
+ yield None, "\n".join(log_lines[-30:])
168
+
169
+ proc.wait()
170
+ log = "\n".join(log_lines)
171
+
172
+ if proc.returncode != 0 or not out_file.exists():
173
+ yield None, log + "\n\n[ERROR] Generation failed."
174
+ return
175
+
176
+ final = tempfile.NamedTemporaryFile(suffix=".mp4", delete=False)
177
+ shutil.copy2(out_file, final.name)
178
+ yield final.name, log + "\n\n[DONE]"
179
+
180
+
181
+ with gr.Blocks(title="Sulphur — Image to Video") as demo:
182
+ gr.Markdown("# Sulphur — Image to Video\nUsing New "Experimental" Lora v1")
183
  with gr.Row():
184
+ with gr.Column(scale=1):
185
+ image_in = gr.Image(type="filepath", label="Input Image")
186
+ prompt_in = gr.Textbox(label="Prompt", placeholder="Describe the motion…", lines=3)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
187
  with gr.Accordion("Advanced", open=False):
188
+ resolution_dd = gr.Dropdown(RESOLUTIONS, value="832x480", label="Resolution")
189
+ steps_sl = gr.Slider(1, 50, value=8, step=1, label="Steps")
190
+ guidance_sl = gr.Slider(1.0, 10.0, value=5.0, step=0.5, label="Guidance Scale")
191
+ frames_sl = gr.Slider(17, 257, value=81, step=8, label="Frames")
192
+ seed_num = gr.Number(value=-1, label="Seed (-1 = random)", precision=0)
193
+ run_btn = gr.Button("Generate", variant="primary")
194
+ with gr.Column(scale=1):
195
+ video_out = gr.Video(label="Output Video")
196
+ log_out = gr.Textbox(label="Log", lines=10, interactive=False)
197
+
198
+ run_btn.click(
 
 
199
  fn=generate_video,
200
+ inputs=[image_in, prompt_in, resolution_dd, steps_sl, guidance_sl, frames_sl, seed_num],
201
+ outputs=[video_out, log_out],
202
  )
203
 
 
204
  if __name__ == "__main__":
205
+ demo.launch(theme=gr.themes.Soft())
generate.py ADDED
@@ -0,0 +1,139 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ HF Spaces version of generate.py — same logic, paths adapted for /tmp/Wan2GP.
3
+ Called as a subprocess from app.py inside @spaces.GPU.
4
+ """
5
+
6
+ import argparse
7
+ import os
8
+ import sys
9
+ from pathlib import Path
10
+
11
+ WAN2GP_ROOT = Path(os.environ.get("WAN2GP_ROOT", "/tmp/Wan2GP"))
12
+
13
+ MODEL_SHORTHANDS = {
14
+ "sulphur-2": "sulphur_2_base",
15
+ }
16
+
17
+ DEFAULTS = {
18
+ "sulphur_2_base": {
19
+ "num_inference_steps": 8,
20
+ "guidance_scale": 5.0,
21
+ "resolution": "832x480",
22
+ "video_length": 81,
23
+ },
24
+ }
25
+
26
+
27
+ def p(*args, **kwargs):
28
+ print(*args, **kwargs, flush=True)
29
+
30
+
31
+ def parse_args():
32
+ ap = argparse.ArgumentParser()
33
+ ap.add_argument("--image", required=True)
34
+ ap.add_argument("--prompt", required=True)
35
+ ap.add_argument("--output", required=True)
36
+ ap.add_argument("--model", default="sulphur-2")
37
+ ap.add_argument("--steps", type=int, default=None)
38
+ ap.add_argument("--guidance_scale", type=float, default=None)
39
+ ap.add_argument("--frames", type=int, default=None)
40
+ ap.add_argument("--resolution", default=None)
41
+ ap.add_argument("--seed", type=int, default=-1)
42
+ return ap.parse_args()
43
+
44
+
45
+ def main():
46
+ args = parse_args()
47
+
48
+ model_type = MODEL_SHORTHANDS.get(args.model, args.model)
49
+ defaults = DEFAULTS.get(model_type, DEFAULTS["sulphur_2_base"])
50
+
51
+ image_path = str(Path(args.image.strip()).resolve())
52
+ if not Path(image_path).exists():
53
+ print(f"Fatal: image not found: {image_path}", flush=True)
54
+ sys.exit(1)
55
+
56
+ resolution = args.resolution or defaults["resolution"]
57
+ if not args.resolution:
58
+ try:
59
+ from PIL import Image as _PIL
60
+ img = _PIL.open(image_path)
61
+ iw, ih = img.size
62
+ if ih > iw:
63
+ tw = 480
64
+ th = round(ih / iw * tw / 32) * 32
65
+ else:
66
+ th = 480
67
+ tw = round(iw / ih * th / 32) * 32
68
+ resolution = f"{tw}x{th}"
69
+ p(f"Auto-detected resolution: {resolution} (from {iw}x{ih} input)")
70
+ except Exception:
71
+ pass
72
+
73
+ task = {
74
+ "model_type": model_type,
75
+ "base_model_type": model_type,
76
+ "prompt": args.prompt,
77
+ "image_start": image_path,
78
+ "num_inference_steps": args.steps or defaults["num_inference_steps"],
79
+ "guidance_scale": args.guidance_scale or defaults["guidance_scale"],
80
+ "resolution": resolution,
81
+ "video_length": args.frames or defaults["video_length"],
82
+ "seed": args.seed,
83
+ "image_prompt_type": "S",
84
+ "input_video_strength": 1.0,
85
+ "activated_loras": [
86
+ "sulphur_experimental_lora_v1.safetensors",
87
+ ],
88
+ "loras_multipliers": ["0.5"],
89
+ }
90
+
91
+ p(f"Model: {model_type}")
92
+ p(f"Image: {image_path}")
93
+ p(f"Steps: {task['num_inference_steps']} Guidance: {task['guidance_scale']}")
94
+ p(f"Resolution: {task['resolution']} Frames: {task['video_length']}")
95
+ p(f"Prompt: {args.prompt[:80]}")
96
+
97
+ sys.path.insert(0, str(WAN2GP_ROOT))
98
+ os.chdir(WAN2GP_ROOT)
99
+
100
+ from shared.api import WanGPSession
101
+
102
+ output_dir = Path(args.output).parent
103
+ output_dir.mkdir(parents=True, exist_ok=True)
104
+
105
+ p("Starting session...")
106
+ session = WanGPSession(root=WAN2GP_ROOT, output_dir=output_dir, console_output=True)
107
+
108
+ p("Running generation...")
109
+ result = session.run_task(task)
110
+
111
+ output_file = None
112
+
113
+ if result.artifacts:
114
+ src = result.artifacts[0].path
115
+ if src and Path(src).exists():
116
+ output_file = src
117
+
118
+ # Fallback: scan the output dir for any video file Wan2GP may have written
119
+ if output_file is None:
120
+ candidates = sorted(output_dir.glob("**/*.mp4"), key=lambda f: f.stat().st_mtime, reverse=True)
121
+ if candidates:
122
+ output_file = str(candidates[0])
123
+ p(f"Found output via dir scan: {output_file}")
124
+
125
+ if output_file:
126
+ import shutil
127
+ shutil.copy2(output_file, args.output)
128
+ p(f"Done: {args.output}")
129
+ else:
130
+ p(f"No output found in {output_dir}")
131
+ if result.errors:
132
+ p(f"Errors: {result.errors}")
133
+ sys.exit(1)
134
+
135
+ session.close()
136
+
137
+
138
+ if __name__ == "__main__":
139
+ main()
requirements.txt CHANGED
@@ -1,19 +1,74 @@
1
- gradio>=5.29.0
 
2
  spaces
3
  huggingface_hub>=0.24.0
4
 
5
- torch==2.8.0
6
- torchaudio==2.8.0
 
 
 
 
 
 
 
7
  einops
8
- numpy
9
- transformers==4.57.6
10
- safetensors
11
- accelerate
12
- scipy
13
  av
14
- tqdm
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
15
  Pillow
16
- sentencepiece
17
- scikit-image>=0.25.2
18
- flashpack==0.1.2
19
- llama-cpp-python @ https://github.com/abetlen/llama-cpp-python/releases/download/v0.3.16-cu124/llama_cpp_python-0.3.16-cp312-cp312-linux_x86_64.whl
 
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