sulphur2-endpoint / handler.py
komkom0428's picture
add custom handler, requirements, Dockerfile for LTX-2.3 / Sulphur-2-base
055bfe3 verified
Raw
History Blame Contribute Delete
6.42 kB
"""HF Inference Endpoint handler for SulphurAI/Sulphur-2-base (LTX-2.3 fine-tune).
The pipeline is constructed against the low-level `ltx_pipelines.DistilledPipeline`
API rather than `diffusers`, because Sulphur ships raw .safetensors and explicitly
recommends the distilled checkpoint + distill LoRA workflow.
Required environment:
- LTX-2 (Lightricks/LTX-2) installed: `ltx_core`, `ltx_pipelines` importable.
The official install path is `uv sync` from a git clone — see Dockerfile.
- HF_TOKEN set as an Endpoint secret. The Gemma text encoder is gated, so the
HF account behind the token must have accepted its license.
- GPU with >= 48 GB VRAM (H100 80GB / A100 80GB recommended). Distilled bf16
plus Gemma 12B comfortably exceeds 40 GB at fp16.
"""
from __future__ import annotations
import base64
import logging
import os
import tempfile
import uuid
from typing import Any, Dict, Iterator
import torch
from huggingface_hub import hf_hub_download, snapshot_download
logger = logging.getLogger("sulphur2.handler")
logging.basicConfig(level=logging.INFO)
SULPHUR_REPO = "SulphurAI/Sulphur-2-base"
SULPHUR_DISTILLED = "sulphur_distil_bf16.safetensors"
SULPHUR_DISTILL_LORA = "distill_loras/ltx-2.3-22b-distilled-lora-1.1_fro90_ceil72_condsafe.safetensors"
LTX_REPO = "Lightricks/LTX-2.3"
LTX_SPATIAL_UPSCALER = "ltx-2.3-spatial-upscaler-x2-1.1.safetensors"
GEMMA_REPO = "google/gemma-3-12b-it-qat-q4_0-unquantized"
def _import_ltx():
"""Import LTX-2 lazily so import errors surface at handler init, not module load."""
from ltx_core.loader import LoraPathStrengthAndSDOps
from ltx_pipelines import DistilledPipeline
from ltx_pipelines.utils.media_io import encode_video
return DistilledPipeline, LoraPathStrengthAndSDOps, encode_video
class EndpointHandler:
def __init__(self, model_dir: str, **kwargs: Any) -> None:
token = os.getenv("HF_TOKEN") or os.getenv("HUGGING_FACE_HUB_TOKEN")
if not token:
raise RuntimeError(
"HF_TOKEN (or HUGGING_FACE_HUB_TOKEN) is required. Accept the Gemma "
"license on huggingface.co/google/gemma-3-12b-it-qat-q4_0-unquantized "
"and add the token as an Endpoint secret."
)
DistilledPipeline, LoraPathStrengthAndSDOps, encode_video = _import_ltx()
self._encode_video = encode_video
logger.info("Downloading Sulphur distilled checkpoint...")
checkpoint_path = hf_hub_download(
repo_id=SULPHUR_REPO, filename=SULPHUR_DISTILLED, token=token
)
logger.info("Downloading Sulphur distill LoRA...")
distill_lora_path = hf_hub_download(
repo_id=SULPHUR_REPO, filename=SULPHUR_DISTILL_LORA, token=token
)
logger.info("Downloading LTX-2.3 spatial upscaler...")
spatial_upscaler_path = hf_hub_download(
repo_id=LTX_REPO, filename=LTX_SPATIAL_UPSCALER, token=token
)
logger.info("Downloading Gemma 3 text encoder snapshot...")
gemma_root = snapshot_download(
repo_id=GEMMA_REPO,
token=token,
allow_patterns=[
"*.json",
"*.model",
"*.safetensors",
"tokenizer*",
"special_tokens_map.json",
],
)
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
logger.info("Initializing DistilledPipeline on %s...", device)
loras = [LoraPathStrengthAndSDOps(path=distill_lora_path, strength=1.0)]
self.pipeline = DistilledPipeline(
distilled_checkpoint_path=checkpoint_path,
gemma_root=gemma_root,
spatial_upsampler_path=spatial_upscaler_path,
loras=loras,
device=device,
)
logger.info("Pipeline ready.")
@torch.inference_mode()
def __call__(self, data: Dict[str, Any]) -> Dict[str, Any]:
prompt = data.get("inputs") or data.get("prompt")
if not isinstance(prompt, str) or not prompt.strip():
return {"error": "Request must include a non-empty 'inputs' or 'prompt' string."}
params = data.get("parameters") or {}
width = int(params.get("width", 768))
height = int(params.get("height", 512))
num_frames = int(params.get("num_frames", 97))
fps = int(params.get("fps", 24))
seed = int(params.get("seed", 0))
enhance_prompt = bool(params.get("enhance_prompt", False))
if num_frames < 9 or num_frames > 257:
return {"error": "num_frames must be between 9 and 257."}
if width % 32 or height % 32:
return {"error": "width and height must be multiples of 32."}
logger.info(
"Generating: prompt=%r seed=%d %dx%d frames=%d fps=%d",
prompt[:80], seed, width, height, num_frames, fps,
)
video_iter, audio = self.pipeline(
prompt=prompt,
seed=seed,
height=height,
width=width,
num_frames=num_frames,
frame_rate=float(fps),
images=[],
tiling_config=None,
enhance_prompt=enhance_prompt,
)
chunks = list(video_iter) if isinstance(video_iter, Iterator) else [video_iter]
if not chunks:
return {"error": "Pipeline produced no frames."}
video_chunks_number = len(chunks)
tmp_path = os.path.join(tempfile.gettempdir(), f"sulphur_{uuid.uuid4().hex}.mp4")
try:
self._encode_video(
video=iter(chunks),
fps=fps,
audio=audio,
output_path=tmp_path,
video_chunks_number=video_chunks_number,
)
with open(tmp_path, "rb") as f:
payload = f.read()
finally:
if os.path.exists(tmp_path):
try:
os.remove(tmp_path)
except OSError:
pass
return {
"content_type": "video/mp4",
"encoding": "base64",
"video_base64": base64.b64encode(payload).decode("ascii"),
"metadata": {
"width": width,
"height": height,
"num_frames": num_frames,
"fps": fps,
"seed": seed,
},
}