Spaces:
Running on Zero
Running on Zero
Upload 3 files
Browse filesrevert back to working
- app.py +175 -505
- generate.py +139 -0
- requirements.txt +68 -13
app.py
CHANGED
|
@@ -1,535 +1,205 @@
|
|
| 1 |
-
|
| 2 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3 |
import os
|
| 4 |
-
import random
|
| 5 |
-
import struct
|
| 6 |
-
import subprocess
|
| 7 |
import sys
|
|
|
|
|
|
|
| 8 |
import tempfile
|
| 9 |
-
import
|
|
|
|
| 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 |
-
|
| 98 |
-
|
| 99 |
-
|
| 100 |
-
|
| 101 |
-
|
| 102 |
-
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
|
| 106 |
-
|
| 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 |
-
|
| 132 |
-
"
|
| 133 |
-
|
| 134 |
-
|
| 135 |
-
|
| 136 |
-
|
| 137 |
-
|
| 138 |
-
|
| 139 |
-
|
| 140 |
-
|
| 141 |
-
|
| 142 |
-
"
|
| 143 |
-
"
|
|
|
|
|
|
|
|
|
|
| 144 |
}
|
| 145 |
|
| 146 |
-
|
| 147 |
-
|
| 148 |
-
|
| 149 |
-
|
| 150 |
-
|
| 151 |
-
|
| 152 |
-
|
| 153 |
-
|
| 154 |
-
|
| 155 |
-
|
| 156 |
-
|
| 157 |
-
|
| 158 |
-
|
| 159 |
-
|
| 160 |
-
|
| 161 |
-
|
| 162 |
-
|
| 163 |
-
|
| 164 |
-
|
| 165 |
-
|
| 166 |
-
|
| 167 |
-
|
| 168 |
-
|
| 169 |
-
|
| 170 |
-
|
| 171 |
-
|
| 172 |
-
|
| 173 |
-
|
| 174 |
-
|
| 175 |
-
|
| 176 |
-
|
| 177 |
-
|
| 178 |
-
|
| 179 |
-
|
| 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 |
-
|
|
|
|
| 241 |
|
| 242 |
-
|
| 243 |
-
|
| 244 |
-
|
| 245 |
-
|
| 246 |
-
|
| 247 |
-
|
| 248 |
-
|
| 249 |
-
|
| 250 |
-
|
| 251 |
-
|
| 252 |
-
|
| 253 |
-
|
| 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 |
-
|
| 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 |
-
|
| 426 |
-
|
| 427 |
-
|
| 428 |
-
|
| 429 |
-
|
| 430 |
-
|
| 431 |
-
|
| 432 |
-
|
| 433 |
-
|
| 434 |
-
|
| 435 |
-
|
| 436 |
-
|
| 437 |
-
|
| 438 |
-
|
| 439 |
-
|
| 440 |
-
|
| 441 |
-
|
| 442 |
-
|
| 443 |
-
|
| 444 |
-
|
| 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 |
-
|
| 499 |
-
|
| 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 |
-
|
| 516 |
-
|
| 517 |
-
|
| 518 |
-
|
| 519 |
-
|
| 520 |
-
|
| 521 |
-
with gr.Column():
|
| 522 |
-
|
| 523 |
-
|
| 524 |
-
|
| 525 |
-
|
| 526 |
-
|
| 527 |
-
generate_btn.click(fn=prepare_prompt, inputs=[prompt, enhance_prompt], outputs=[prepared_prompt]).then(
|
| 528 |
fn=generate_video,
|
| 529 |
-
inputs=[
|
| 530 |
-
outputs=[
|
| 531 |
)
|
| 532 |
|
| 533 |
-
|
| 534 |
if __name__ == "__main__":
|
| 535 |
-
demo.launch(theme=gr.themes.
|
|
|
|
| 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 |
-
|
|
|
|
| 2 |
spaces
|
| 3 |
huggingface_hub>=0.24.0
|
| 4 |
|
| 5 |
-
|
| 6 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 7 |
einops
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
| 13 |
av
|
| 14 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 15 |
Pillow
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
| 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
|
|
|