chore: import upstream snapshot with attribution
PR Test (NPU) / check-changes (push) Has been cancelled
PR Test (NPU) / pr-gate (push) Has been cancelled
PR Test (NPU) / set-image-config (push) Has been cancelled
PR Test (NPU) / stage-b-test-1-npu-a2 (0) (push) Has been cancelled
PR Test (NPU) / stage-b-test-1-npu-a2 (1) (push) Has been cancelled
PR Test (NPU) / stage-b-test-2-npu-a2 (0) (push) Has been cancelled
PR Test (NPU) / stage-b-test-2-npu-a2 (1) (push) Has been cancelled
PR Test (NPU) / stage-b-test-4-npu-a3 (push) Has been cancelled
PR Test (NPU) / stage-b-test-16-npu-a3 (push) Has been cancelled
PR Test (NPU) / multimodal-gen-test-1-npu-a3 (push) Has been cancelled
PR Test (NPU) / multimodal-gen-test-2-npu-a3 (push) Has been cancelled
PR Test (Arm64) / pr-gate (push) Has been cancelled
PR Test (Arm64) / check-changes (push) Has been cancelled
PR Test (Arm64) / build-test (push) Has been cancelled
PR Test (sgl-router) / gate (push) Has been cancelled
PR Test (sgl-router) / tier-1 — lint (push) Has been cancelled
PR Test (sgl-router) / tier-2 — build + test (push) Has been cancelled
PR Test (sgl-router) / tier-3 — docker (placeholder) (push) Has been cancelled
PR Test (sgl-router) / tier-3 — k8s integration (push) Has been cancelled
PR Test (sgl-router) / tier-3 — e2e (push) Has been cancelled
PR Test (sgl-router) / finish (push) Has been cancelled
PR Test (NPU) / single-node-poc (map[name:qwen3_6_27b_w8a8_1p_in64k_out1k_50ms runner:linux-aarch64-a3-2 test_case:test/registered/ascend/performance/qwen3_6_27b/test_npu_qwen3_6_27b_w8a8_1p_in64k_out1k_50ms.py test_type:perf]) (push) Has been cancelled
PR Test (NPU) / pr-test-npu-finish (push) Has been cancelled
PR Test (Xeon) / pr-gate (push) Has been cancelled
PR Test (Xeon) / check-changes (push) Has been cancelled
PR Test (Xeon) / build-test (, xeon-gnr, base-b-test-cpu) (push) Has been cancelled
PR Test (XPU) / check-changes (push) Has been cancelled
PR Test (XPU) / pr-gate (push) Has been cancelled
PR Test (XPU) / stage-a-test-1-gpu-xpu (push) Has been cancelled
PR Test (XPU) / wait-for-stage-a (push) Has been cancelled
PR Test (XPU) / stage-b-test-1-gpu-xpu (push) Has been cancelled
PR Test (XPU) / finish (push) Has been cancelled
CI Model Inventory / build-inventory (push) Has been cancelled
Lint / lint (push) Has been cancelled
PR Benchmark (SMG Components) / Benchmark Compilation Check (push) Has been cancelled
PR Benchmark (SMG Components) / Benchmark - Manual Policy (push) Has been cancelled
PR Benchmark (SMG Components) / Benchmark - Request Processing (push) Has been cancelled
PR Benchmark (SMG Components) / Benchmark Summary (push) Has been cancelled
PR Test (SMG) / build-wheel (push) Has been cancelled
Release SGLang Model Gateway to PyPI / build on windows (x86_64 - auto) (push) Has been cancelled
Release SGLang Model Gateway to PyPI / build on macos (x86_64 - auto) (push) Has been cancelled
PR Test (SMG) / python-unit-tests (push) Has been cancelled
PR Test (SMG) / unit-tests (push) Has been cancelled
PR Test (SMG) / benchmarks (push) Has been cancelled
PR Test (SMG) / chat-completions (push) Has been cancelled
PR Test (SMG) / chat-completions-4gpu (push) Has been cancelled
PR Test (SMG) / e2e (push) Has been cancelled
PR Test (SMG) / docker-build-test (push) Has been cancelled
PR Test (SMG) / k8s-integration (push) Has been cancelled
PR Test (SMG) / finish (push) Has been cancelled
PR Test (SMG) / summarize-benchmarks (push) Has been cancelled
Release SGLang Model Gateway Docker Image / publish (push) Has been cancelled
Release SGLang Model Gateway to PyPI / build on macos (aarch64 - auto) (push) Has been cancelled
Release SGLang Model Gateway to PyPI / build on linux (aarch64 - auto) (push) Has been cancelled
Release SGLang Model Gateway to PyPI / build on linux (x86_64 - auto) (push) Has been cancelled
Release SGLang Model Gateway to PyPI / build on linux (aarch64 - musllinux_1_1) (push) Has been cancelled
Release SGLang Model Gateway to PyPI / build on linux (x86_64 - musllinux_1_1) (push) Has been cancelled
Release SGLang Model Gateway to PyPI / Build SDist (push) Has been cancelled
Release SGLang Model Gateway to PyPI / Upload to PyPI (push) Has been cancelled
Release SGLang Kernels / build-cu129-matrix (aarch64, 12.9, 3.10, arm-kernel-build-node) (push) Has been cancelled
Release SGLang Kernels / build-cu129-matrix (x86_64, 12.9, 3.10, x64-kernel-build-node) (push) Has been cancelled
Release SGLang Kernels / release-cu129 (push) Has been cancelled
Release SGLang Kernels / build-cu130-matrix (aarch64, 13.0, 3.10, arm-kernel-build-node) (push) Has been cancelled
Release SGLang Kernels / build-cu130-matrix (x86_64, 13.0, 3.10, x64-kernel-build-node) (push) Has been cancelled
Release SGLang Kernels / release-cu130 (push) Has been cancelled
Release SGLang Kernels / build-rocm-matrix (3.10, 700) (push) Has been cancelled
Release SGLang Kernels / build-rocm-matrix (3.10, 720) (push) Has been cancelled
Release SGLang Kernels / release-rocm700 (push) Has been cancelled
Release SGLang Kernels / release-rocm720 (push) Has been cancelled
Release SGLang Kernels / build-musa43 (43, 3.10) (push) Has been cancelled
Release SGLang Kernels / release-musa43 (push) Has been cancelled

This commit is contained in:
wehub-resource-sync
2026-07-13 12:38:16 +08:00
commit 94057c3d3e
7152 changed files with 2120455 additions and 0 deletions
@@ -0,0 +1,58 @@
# ComfyUI SGLDiffusion Plugin
A ComfyUI plugin for integrating with SGLang Diffusion server, supporting image and video generation capabilities.
## Installation
1. **Install SGLang**: Follow the [Installation Guide](../../../../../docs/diffusion/installation.md) to install `sglang[diffusion]`.
2. **Install Plugin**: Copy this entire directory (`ComfyUI_SGLDiffusion`) to your ComfyUI `custom_nodes/` folder.
3. **Restart ComfyUI**: Restart ComfyUI to load the plugin.
## Usage
The plugin supports two modes of operation: **Server Mode** (via HTTP API) and **Integrated Mode** (tight integration with ComfyUI).
### Supported Models
- **Z-Image**: High-speed image generation models (e.g., `Z-Image-Turbo`)
- **FLUX**: State-of-the-art text-to-image models (e.g., `FLUX.1-dev`)
- **Qwen-Image**: Multi-modal image generation models (e.g., `Qwen-Image`,`Qwen-Image-2512`). *Note: Image editing support is currently experimental and may have some issues.*
### Mode 1: Server Mode (HTTP API)
Connect to a standalone SGLang Diffusion server.
1. **Start SGLang Diffusion Server**: Ensure the server is running and accessible.
2. **Connect to Server**: Use the `SGLDiffusion Server Model` node to connect (default: `http://localhost:3000/v1`).
3. **Generate Content**:
- `SGLDiffusion Generate Image`: For text-to-image and image editing.
- `SGLDiffusion Generate Video`: For text-to-video and image-to-video.
4. **LoRA Support**: Use `SGLDiffusion Server Set LoRA` and `SGLDiffusion Server Unset LoRA`.
### Mode 2: Integrated Mode (Tight Integration)
Leverage SGLang's high-performance sampling directly within ComfyUI while using ComfyUI's front-end nodes (CLIP, VAE, etc.).
1. **Load Model**: Use the `SGLDiffusion UNET Loader` node to load your diffusion model.
2. **Configure Options**: Use the `SGLDiffusion Options` node to set runtime parameters like `num_gpus`, `tp_size`, `model_type`, or `enable_torch_compile`.
3. **Sample**: Connect the loaded model to standard ComfyUI samplers. SGLang will handle the sampling process efficiently.
4. **LoRA Support**: Use the `SGLDiffusion LoRA Loader` for native LoRA integration.
## Example Workflows
Reference workflow files are provided in the `workflows/` directory:
- **`flux_sgld_sp.json`**: Multi-GPU (Sequence Parallelism) workflow for FLUX models. High-performance inference across multiple cards.
- **`qwen_image_sgld.json`**: Qwen-Image generation with LoRA support. Optimized for multi-modal image tasks.
- **`z-image_sgld.json`**: High-speed image generation using Z-Image.
- **`sgld_text2img.json`**: Server-mode text-to-image generation with LoRA support.
- **`sgld_image2video.json`**: Server-mode image-to-video generation.
For other workflows supporting the models, you can easily use SGLang by replacing the official `UNET Loader` node with the `SGLDUNETLoader` node. Similarly, for LoRA support, replace the official LoRA loader with the `SGLDiffusion LoRA Loader`.
To use these workflows:
1. Open ComfyUI.
2. Load the workflow JSON file from the `workflows/` directory.
3. Adjust the parameters and model paths as needed.
4. Run the workflow.
## Current Implementation
This plugin provides a high-performance backend for diffusion models in ComfyUI. By leveraging SGLang's optimized kernels and parallelization techniques (Tensor Parallelism, TeaCache, etc.), it significantly accelerates the sampling process, especially for large models like FLUX.
@@ -0,0 +1,13 @@
"""
ComfyUI SGLang Diffusion nodes package.
"""
try:
from .nodes import NODE_CLASS_MAPPINGS, NODE_DISPLAY_NAME_MAPPINGS
__all__ = ["NODE_CLASS_MAPPINGS", "NODE_DISPLAY_NAME_MAPPINGS"]
except ImportError:
# ComfyUI dependencies not available (e.g., in test environment)
NODE_CLASS_MAPPINGS = {}
NODE_DISPLAY_NAME_MAPPINGS = {}
__all__ = ["NODE_CLASS_MAPPINGS", "NODE_DISPLAY_NAME_MAPPINGS"]
@@ -0,0 +1,14 @@
"""
Core components for SGLang Diffusion ComfyUI integration.
Provides generator, model patcher, and server API client.
"""
from .generator import SGLDiffusionGenerator
from .model_patcher import SGLDModelPatcher
from .server_api import SGLDiffusionServerAPI
__all__ = [
"SGLDiffusionGenerator",
"SGLDModelPatcher",
"SGLDiffusionServerAPI",
]
@@ -0,0 +1,231 @@
"""
Generator for SGLang Diffusion ComfyUI integration.
"""
import logging
import os
import psutil
from comfy import model_detection, model_management
from comfy.utils import (
calculate_parameters,
load_torch_file,
state_dict_prefix_replace,
unet_to_diffusers,
)
logger = logging.getLogger(__name__)
try:
from sglang.multimodal_gen import DiffGenerator
except ImportError:
logger.error(
"Error: sglang.multimodal_gen is not installed. Please install it using 'pip install sglang[diffusion]'"
)
from ..executors import (
FluxExecutor,
QwenImageEditExecutor,
QwenImageExecutor,
ZImageExecutor,
)
from .model_patcher import SGLDModelPatcher
class SGLDiffusionGenerator:
"""Generator for SGLang Diffusion models in ComfyUI."""
def __init__(self):
self.model_path = None
self.generator = None
self.executor = None
self.last_options = None
self.pipeline_class_dict = {
"flux": "ComfyUIFluxPipeline",
"lumina2": "ComfyUIZImagePipeline", # zimage
"qwen_image": "ComfyUIQwenImagePipeline",
"qwen_image_edit": "ComfyUIQwenImageEditPipeline",
}
self.executor_class_dict = {
"flux": FluxExecutor,
"lumina2": ZImageExecutor,
"qwen_image": QwenImageExecutor,
"qwen_image_edit": QwenImageEditExecutor,
}
def __del__(self):
self.close_generator()
def init_generator(
self, model_path: str, pipeline_class_name: str, kwargs: dict = None
):
"""Initialize the diffusion generator."""
if self.generator is not None:
return self.generator
if kwargs is None:
kwargs = {}
# Set comfyui_mode for ComfyUI integration
kwargs["comfyui_mode"] = True
self.generator = DiffGenerator.from_pretrained(
model_path=model_path,
pipeline_class_name=pipeline_class_name,
**kwargs,
)
return self.generator
def kill_generator(self):
"""Kill worker processes manually because generator shutdown cannot terminate them."""
current_pid = os.getpid()
worker_processes = []
for proc in psutil.process_iter(["pid", "name", "cmdline"]):
try:
# Look for sglang-diffusionWorker processes
if proc.info["cmdline"]:
cmdline = " ".join(proc.info["cmdline"])
if "sgl_diffusion::" in cmdline:
if proc.info["pid"] != current_pid:
worker_processes.append(proc)
except (psutil.NoSuchProcess, psutil.AccessDenied):
continue
if worker_processes:
logger.info(
f"Found {len(worker_processes)} worker processes to terminate..."
)
for proc in worker_processes:
try:
logger.info(
f"Terminating worker process {proc.info['pid']}: {proc.info['name']}"
)
proc.terminate()
proc.wait(timeout=5)
except psutil.TimeoutExpired:
logger.warning(
f"Process {proc.info['pid']} did not terminate, forcing kill..."
)
try:
proc.kill()
proc.wait(timeout=2)
except (psutil.NoSuchProcess, psutil.TimeoutExpired):
pass
except (psutil.NoSuchProcess, psutil.AccessDenied):
pass
def close_generator(self):
"""Close and cleanup the generator and all associated resources."""
if self.generator is not None:
self.generator.shutdown()
self.kill_generator()
# Clear other references
self.last_options = None
self.model_path = None
self.generator = None
self.executor = None
def get_comfyui_model(self, model_path: str, model_options: dict = None):
"""Get ComfyUI model from model path."""
if model_options is None:
model_options = {}
dtype = model_options.get("dtype", None)
# Allow loading unets from checkpoint files
sd = load_torch_file(model_path)
diffusion_model_prefix = model_detection.unet_prefix_from_state_dict(sd)
temp_sd = state_dict_prefix_replace(
sd, {diffusion_model_prefix: ""}, filter_keys=True
)
if len(temp_sd) > 0:
sd = temp_sd
parameters = calculate_parameters(sd)
load_device = model_management.get_torch_device()
model_detect_config = model_detection.detect_unet_config(sd, "")
model_type = model_detect_config.get("image_model", None)
if model_type is None or model_type not in self.pipeline_class_dict:
raise ValueError(f"Unsupported model type: {model_type}")
model_config = model_detection.model_config_from_unet(sd, "")
if model_config is not None:
new_sd = sd
else:
new_sd = model_detection.convert_diffusers_mmdit(sd, "")
if new_sd is not None: # diffusers mmdit
model_config = model_detection.model_config_from_unet(new_sd, "")
if model_config is None:
return None
else: # diffusers unet
model_config = model_detection.model_config_from_diffusers_unet(sd)
if model_config is None:
return None
diffusers_keys = unet_to_diffusers(model_config.unet_config)
new_sd = {}
for k in diffusers_keys:
if k in sd:
new_sd[diffusers_keys[k]] = sd.pop(k)
if dtype is None:
unet_dtype = model_management.unet_dtype(
model_params=parameters,
supported_dtypes=model_config.supported_inference_dtypes,
)
else:
unet_dtype = dtype
manual_cast_dtype = model_management.unet_manual_cast(
unet_dtype, load_device, model_config.supported_inference_dtypes
)
model_config.set_inference_dtype(unet_dtype, manual_cast_dtype)
model_config.custom_operations = model_options.get("custom_operations", None)
model_config.unet_config["disable_unet_model_creation"] = True
comfyui_model = model_config.get_model({})
return comfyui_model, model_config, model_type
def load_model(
self, model_path: str, model_options: dict = None, sgld_options: dict = None
):
"""Load model and return model patcher."""
gather_options = {
"model_path": model_path,
"model_options": model_options,
"sgld_options": sgld_options,
}
if (
self.last_options is not None
and self.last_options == gather_options
and self.generator is not None
):
return self.generator
else:
self.close_generator()
self.last_options = gather_options
self.model_path = model_path
comfyui_model, model_config, model_type = self.get_comfyui_model(
model_path, model_options
)
if model_type is None or model_type not in self.pipeline_class_dict:
raise ValueError(f"Unsupported model type: {model_type}")
set_model_type = sgld_options.pop("model_type", None) if sgld_options else None
if set_model_type is not None and set_model_type in self.pipeline_class_dict:
model_type = set_model_type
pipeline_class_name = self.pipeline_class_dict[model_type]
self.generator = self.init_generator(
model_path, pipeline_class_name, sgld_options
)
executor_class = self.executor_class_dict[model_type]
self.executor = executor_class(
self.generator, model_path, comfyui_model, model_config
)
comfyui_model.diffusion_model = self.executor
load_device = model_management.get_torch_device()
offload_device = model_management.unet_offload_device()
return SGLDModelPatcher(
comfyui_model, load_device, offload_device, model_type=model_type
)
@@ -0,0 +1,82 @@
"""
Model patcher for SGLang Diffusion ComfyUI integration.
"""
import copy
from comfy.model_patcher import ModelPatcher
class SGLDModelPatcher(ModelPatcher):
"""Model patcher for SGLang Diffusion models in ComfyUI."""
def __init__(
self,
model,
load_device,
offload_device,
size=0,
weight_inplace_update=False,
model_type=None,
):
super().__init__(
model, load_device, offload_device, size, weight_inplace_update
)
self.lora_cache = {}
self.model_type = model_type
self.model_size_dict = {
"flux": 27 * 1024 * 1024 * 1024,
"lumina2": 8 * 1024 * 1024 * 1024,
}
def clone(self):
"""Clone the model patcher."""
n = SGLDModelPatcher(
self.model,
self.load_device,
self.offload_device,
self.size,
weight_inplace_update=self.weight_inplace_update,
)
n.patches = {}
for k in self.patches:
n.patches[k] = self.patches[k][:]
n.patches_uuid = self.patches_uuid
n.object_patches = self.object_patches.copy()
n.model_options = copy.deepcopy(self.model_options)
n.backup = self.backup
n.object_patches_backup = self.object_patches_backup
n.lora_cache = copy.copy(self.lora_cache)
return n
def model_size(self):
"""Get the model size in bytes."""
if self.model_type in self.model_size_dict:
return self.model_size_dict[self.model_type]
else:
return 0
def load(
self,
device_to=None,
lowvram_model_memory=0,
force_patch_weights=False,
full_load=False,
):
"""Load model (no-op for SGLang Diffusion)."""
pass
def patch_model(
self,
device_to=None,
lowvram_model_memory=0,
load_weights=True,
force_patch_weights=False,
):
"""Patch model (no-op for SGLang Diffusion)."""
pass
def unpatch_model(self, device_to=None, unpatch_weights=True):
"""Unpatch model (no-op for SGLang Diffusion)."""
pass
@@ -0,0 +1,539 @@
"""
SGLang Diffusion Server API client.
Provides a low-level interface for interacting with SGLang Diffusion HTTP server.
"""
import base64
import io
import os
import time
from typing import Any, Dict, Optional
import requests
from PIL import Image
class SGLDiffusionServerAPI:
"""Client for SGLang Diffusion HTTP server API."""
def __init__(self, base_url: str, api_key: str = "sk-proj-1234567890"):
"""
Initialize the API client.
Args:
base_url: Base URL of the SGLang Diffusion server (e.g., "http://localhost:30010/v1")
api_key: API key for authentication (default: "sk-proj-1234567890")
"""
# Ensure base_url doesn't end with /v1 if it's already there
if base_url.endswith("/v1"):
self.base_url = base_url
elif base_url.endswith("/v1/"):
self.base_url = base_url.rstrip("/")
else:
self.base_url = f"{base_url.rstrip('/')}/v1"
self.api_key = api_key
self.headers = {
"Content-Type": "application/json",
"Authorization": f"Bearer {api_key}",
}
def get_model_info(self) -> Dict[str, Any]:
"""
Get information about the model served by this server.
Returns:
Dictionary containing model information including:
- model_path: Path to the model
- task_type: Type of task (e.g., "T2V", "I2I")
- pipeline_name: Name of the pipeline
- num_gpus: Number of GPUs
- dit_precision: DiT model precision
- vae_precision: VAE model precision
"""
try:
# Remove /v1 from base_url for /models endpoint
models_url = self.base_url.removesuffix("/v1") + "/models"
response = requests.get(models_url, headers=self.headers, timeout=30)
response.raise_for_status()
return response.json()
except requests.exceptions.RequestException as e:
raise RuntimeError(f"Failed to get model info: {str(e)}")
def generate_image(
self,
prompt: str,
image_path: Optional[str] = None,
mask_path: Optional[str] = None,
size: Optional[str] = None,
width: Optional[int] = None,
height: Optional[int] = None,
n: int = 1,
negative_prompt: Optional[str] = None,
guidance_scale: Optional[float] = None,
num_inference_steps: Optional[int] = None,
seed: Optional[int] = None,
enable_teacache: bool = False,
response_format: str = "b64_json",
quality: Optional[str] = "auto",
style: Optional[str] = "vivid",
background: Optional[str] = "auto",
output_format: Optional[str] = None,
generator_device: Optional[str] = "cuda",
) -> Dict[str, Any]:
"""
Generate or edit an image using SGLang Diffusion API.
If image_path is provided, calls the edit endpoint; otherwise calls the generation endpoint.
Args:
prompt: Text prompt for image generation/editing
image_path: Optional path to input image file for editing. If provided, uses edit API.
mask_path: Optional path to mask image file (only used when image_path is provided)
size: Image size in format "WIDTHxHEIGHT" (e.g., "1024x1024")
width: Image width (used if size is not provided)
height: Image height (used if size is not provided)
n: Number of images to generate (1-10)
negative_prompt: Negative prompt to avoid certain elements
guidance_scale: Classifier-free guidance scale
num_inference_steps: Number of denoising steps
seed: Random seed for reproducible generation
enable_teacache: Enable TEA cache acceleration
response_format: Response format ("b64_json" or "url")
quality: Image quality ("auto", "standard", "hd") - only for generation
style: Image style ("vivid" or "natural") - only for generation
background: Background type ("auto", "transparent", "opaque")
output_format: Output format ("png", "jpeg", "webp")
generator_device: Device for random generator ("cuda" or "cpu")
Returns:
Dictionary containing the API response with generated/edited image data
"""
if not prompt:
raise ValueError("Prompt cannot be empty")
# Determine size
if size is None:
if width is not None and height is not None:
size = f"{width}x{height}"
else:
size = "1024x1024"
# Build common parameters
common_params = self._build_image_common_params(
prompt=prompt,
size=size,
n=n,
response_format=response_format,
negative_prompt=negative_prompt,
guidance_scale=guidance_scale,
num_inference_steps=num_inference_steps,
seed=seed,
enable_teacache=enable_teacache,
background=background,
output_format=output_format,
generator_device=generator_device,
)
# If image_path is provided, use edit endpoint
if image_path:
if not os.path.exists(image_path):
raise FileNotFoundError(f"Image file not found: {image_path}")
# Prepare multipart form data for edit
files: Dict[str, Any] = {}
data = common_params.copy()
# Add image file
files["image"] = (
os.path.basename(image_path),
open(image_path, "rb"),
self._get_content_type(image_path),
)
# Add mask file if provided
if mask_path:
if not os.path.exists(mask_path):
raise FileNotFoundError(f"Mask file not found: {mask_path}")
files["mask"] = (
os.path.basename(mask_path),
open(mask_path, "rb"),
self._get_content_type(mask_path),
)
# Prepare headers for multipart form data
headers = {
"Authorization": f"Bearer {self.api_key}",
}
try:
response = requests.post(
f"{self.base_url}/images/edits",
files=files,
data=data,
headers=headers,
timeout=300, # 5 minutes timeout for generation
)
response.raise_for_status()
return response.json()
except requests.exceptions.RequestException as e:
raise RuntimeError(f"Failed to edit image: {str(e)}")
finally:
# Close file handles
for file_tuple in files.values():
if isinstance(file_tuple, tuple) and len(file_tuple) > 1:
file_tuple[1].close()
else:
# Use generation endpoint - add generation-specific parameters
payload = common_params.copy()
if quality:
payload["quality"] = quality
if style:
payload["style"] = style
try:
response = requests.post(
f"{self.base_url}/images/generations",
json=payload,
headers=self.headers,
timeout=300, # 5 minutes timeout for generation
)
response.raise_for_status()
return response.json()
except requests.exceptions.RequestException as e:
raise RuntimeError(f"Failed to generate image: {str(e)}")
def generate_video(
self,
prompt: str,
size: Optional[str] = None,
width: Optional[int] = None,
height: Optional[int] = None,
seconds: Optional[int] = 4,
fps: Optional[int] = None,
num_frames: Optional[int] = None,
negative_prompt: Optional[str] = None,
guidance_scale: Optional[float] = None,
num_inference_steps: Optional[int] = None,
seed: Optional[int] = None,
enable_teacache: bool = False,
generator_device: Optional[str] = "cuda",
input_reference: Optional[str] = None,
output_path: Optional[str] = None,
) -> Dict[str, Any]:
"""
Generate a video using SGLang Diffusion API and wait for completion.
Args:
prompt: Text prompt for video generation
size: Video size in format "WIDTHxHEIGHT" (e.g., "1280x720")
width: Video width (used if size is not provided)
height: Video height (used if size is not provided)
seconds: Duration of the video in seconds
fps: Frames per second
num_frames: Number of frames (overrides seconds * fps if provided)
negative_prompt: Negative prompt to avoid certain elements
guidance_scale: Classifier-free guidance scale
num_inference_steps: Number of denoising steps
seed: Random seed for reproducible generation
enable_teacache: Enable TEA cache acceleration
generator_device: Device for random generator ("cuda" or "cpu")
input_reference: Path to input reference image for image-to-video
Returns:
Dictionary containing completed video job information with file_path
"""
if not prompt:
raise ValueError("Prompt cannot be empty")
# Determine size
if size is None:
if width is not None and height is not None:
size = f"{width}x{height}"
else:
size = "720x1280"
# Prepare request payload
payload: Dict[str, Any] = {
"prompt": prompt,
"size": size,
}
# Add optional parameters
if seconds is not None:
payload["seconds"] = seconds
if fps is not None:
payload["fps"] = fps
if num_frames is not None:
payload["num_frames"] = num_frames
if negative_prompt:
payload["negative_prompt"] = negative_prompt
if guidance_scale is not None:
payload["guidance_scale"] = guidance_scale
if num_inference_steps is not None:
payload["num_inference_steps"] = num_inference_steps
if seed is not None and seed >= 0:
payload["seed"] = seed
if enable_teacache:
payload["enable_teacache"] = True
if generator_device:
payload["generator_device"] = generator_device
if input_reference:
payload["input_reference"] = input_reference
if output_path:
payload["output_path"] = output_path
try:
# Create video generation job
response = requests.post(
f"{self.base_url}/videos",
json=payload,
headers=self.headers,
timeout=30,
)
response.raise_for_status()
video_job = response.json()
video_id = video_job.get("id")
# Wait for completion with fixed polling
poll_interval = 5 # 5 seconds
max_wait_time = 3600 # 1 hour
max_consecutive_errors = 5
consecutive_errors = 0
start_time = time.time()
while time.time() - start_time < max_wait_time:
try:
status_response = requests.get(
f"{self.base_url}/videos/{video_id}",
headers=self.headers,
timeout=30,
)
status_response.raise_for_status()
status = status_response.json()
# Reset error counter on successful request
consecutive_errors = 0
if status.get("status") == "completed":
return status
elif status.get("status") == "failed":
error = status.get("error", {})
error_msg = (
error.get("message", "Unknown error")
if error
else "Unknown error"
)
raise RuntimeError(f"Video generation failed: {error_msg}")
except requests.exceptions.ConnectionError as e:
# Connection errors - likely server is down
consecutive_errors += 1
if consecutive_errors >= max_consecutive_errors:
raise RuntimeError(
f"Lost connection to server after {consecutive_errors} consecutive errors. "
f"Server may be unavailable: {str(e)}"
)
except requests.exceptions.RequestException as e:
# Other network errors - continue polling but track errors
consecutive_errors += 1
if consecutive_errors >= max_consecutive_errors:
raise RuntimeError(
f"Network error after {consecutive_errors} consecutive failures: {str(e)}"
)
time.sleep(poll_interval)
raise TimeoutError(
f"Video generation timed out after {max_wait_time} seconds"
)
except requests.exceptions.RequestException as e:
raise RuntimeError(f"Failed to generate video: {str(e)}")
def _build_image_common_params(
self,
prompt: str,
size: str,
n: int,
response_format: str,
negative_prompt: Optional[str] = None,
guidance_scale: Optional[float] = None,
num_inference_steps: Optional[int] = None,
seed: Optional[int] = None,
enable_teacache: bool = False,
background: Optional[str] = None,
output_format: Optional[str] = None,
generator_device: Optional[str] = None,
) -> Dict[str, Any]:
"""
Build common parameters for both image generation and editing.
Returns:
Dictionary containing common parameters
"""
params: Dict[str, Any] = {
"prompt": prompt,
"size": size,
"n": max(1, min(n, 10)),
"response_format": response_format,
}
# Add optional parameters
if negative_prompt:
params["negative_prompt"] = negative_prompt
if guidance_scale is not None:
params["guidance_scale"] = guidance_scale
if num_inference_steps is not None:
params["num_inference_steps"] = num_inference_steps
if seed is not None and seed >= 0:
params["seed"] = seed
if enable_teacache:
params["enable_teacache"] = True
if background:
params["background"] = background
if output_format:
params["output_format"] = output_format
if generator_device:
params["generator_device"] = generator_device
return params
def _get_content_type(self, file_path: str) -> str:
"""Get content type based on file extension."""
ext = os.path.splitext(file_path)[1].lower()
content_types = {
".png": "image/png",
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
".webp": "image/webp",
}
return content_types.get(ext, "image/png")
def decode_image_from_response(
self, response_data: Dict[str, Any], index: int = 0
) -> Image.Image:
"""
Decode base64 image from API response.
Args:
response_data: API response dictionary
index: Index of the image in the response (default: 0)
Returns:
PIL Image object
"""
if "data" not in response_data or not response_data["data"]:
raise ValueError("No image data in response")
if index >= len(response_data["data"]):
raise IndexError(f"Image index {index} out of range")
image_data = response_data["data"][index]
if "b64_json" not in image_data or not image_data["b64_json"]:
raise ValueError("No base64 image data found")
image_bytes = base64.b64decode(image_data["b64_json"])
image = Image.open(io.BytesIO(image_bytes))
# Convert to RGB if needed
if image.mode != "RGB":
image = image.convert("RGB")
return image
def set_lora(
self,
lora_nickname: str,
lora_path: Optional[str] = None,
target: str = "all",
) -> Dict[str, Any]:
"""
Set a LoRA adapter for the specified transformer(s).
Args:
lora_nickname: The nickname of the adapter (required).
lora_path: Path to the LoRA adapter (local path or HF repo id).
Required for the first load; optional if re-activating a cached nickname.
target: Which transformer(s) to apply the LoRA to. One of:
- "all": Apply to all transformers (default)
- "transformer": Apply only to the primary transformer (high noise for Wan2.2)
- "transformer_2": Apply only to transformer_2 (low noise for Wan2.2)
- "critic": Apply only to the critic model
Returns:
Dictionary containing the API response with status and message
"""
if not lora_nickname:
raise ValueError("lora_nickname cannot be empty")
# Prepare request payload
payload: Dict[str, Any] = {
"lora_nickname": lora_nickname,
"target": target,
}
# Add optional lora_path if provided
if lora_path:
payload["lora_path"] = lora_path
try:
response = requests.post(
f"{self.base_url}/set_lora",
json=payload,
headers=self.headers,
timeout=30,
)
response.raise_for_status()
return response.json()
except requests.exceptions.RequestException as e:
raise RuntimeError(f"Failed to set LoRA adapter: {str(e)}")
def unset_lora(
self,
target: str = "all",
) -> Dict[str, Any]:
"""
Unset (unmerge) LoRA weights from the base model.
Args:
target: same as set_lora
Returns:
Dictionary containing the API response with status and message
"""
# Prepare request payload
payload: Dict[str, Any] = {
"target": target,
}
try:
response = requests.post(
f"{self.base_url}/unmerge_lora_weights",
json=payload,
headers=self.headers,
timeout=30,
)
response.raise_for_status()
return response.json()
except requests.exceptions.RequestException as e:
raise RuntimeError(f"Failed to unset LoRA adapter: {str(e)}")
if __name__ == "__main__":
api = SGLDiffusionServerAPI(
base_url="http://localhost:30010/v1", api_key="sk-proj-1234567890"
)
model_info = api.get_model_info()
print(api.get_model_info())
if model_info.get("task_type") == "T2V" or model_info.get("task_type") == "I2V":
print(
api.generate_video(
prompt="A calico cat playing a piano on stage",
num_inference_steps=1,
size="480x480",
)
)
else:
print(
api.generate_image(
prompt="A calico cat playing a piano on stage", size="1024x1024"
)
)
@@ -0,0 +1,17 @@
"""
ComfyUI SGLang Diffusion executors package.
Provides executor classes for different model types.
"""
from .base import SGLDiffusionExecutor
from .flux import FluxExecutor
from .qwen_image import QwenImageEditExecutor, QwenImageExecutor
from .zimage import ZImageExecutor
__all__ = [
"SGLDiffusionExecutor",
"FluxExecutor",
"ZImageExecutor",
"QwenImageExecutor",
"QwenImageEditExecutor",
]
@@ -0,0 +1,56 @@
"""
Base executor class for SGLang Diffusion ComfyUI integration.
"""
import torch
class SGLDiffusionExecutor(torch.nn.Module):
"""Base executor class for SGLang Diffusion models in ComfyUI."""
def __init__(self, generator, model_path, model, config):
super(SGLDiffusionExecutor, self).__init__()
self.generator = generator
self.model_path = model_path
self.model = model
self.dtype = config.unet_config["dtype"]
self.config = config
self.loras = []
@staticmethod
def should_suppress_logs(timestep):
"""Determine if logs should be suppressed based on timestep value."""
if torch.is_tensor(timestep):
return bool((timestep < 1.0).item())
return bool(timestep < 1.0)
def set_lora(self, lora_nickname=None, lora_path=None, strength=None, target=None):
"""Set LoRA adapter using SGLang Diffusion API."""
if len(lora_nickname) > 0:
self.generator.set_lora(
lora_nickname=lora_nickname,
lora_path=lora_path,
strength=strength,
target=target,
)
def _unpack_latents(self, latents, height, width, channels):
"""Unpack latents from packed format to standard format."""
batch_size = latents.shape[0]
latents = latents.view(batch_size, height // 2, width // 2, channels, 2, 2)
latents = latents.permute(0, 3, 1, 4, 2, 5)
latents = latents.reshape(batch_size, channels, height, width)
return latents
def _pack_latents(self, latents):
"""Pack latents from standard format to packed format."""
batch_size, num_channels_latents, height, width = latents.shape
latents = latents.view(
batch_size, num_channels_latents, height // 2, 2, width // 2, 2
)
latents = latents.permute(0, 2, 4, 1, 3, 5)
latents = latents.reshape(
batch_size, (height // 2) * (width // 2), num_channels_latents * 4
)
return latents
@@ -0,0 +1,69 @@
"""
Flux executor for SGLang Diffusion ComfyUI integration.
"""
import torch
try:
from sglang.multimodal_gen.configs.sample.sampling_params import SamplingParams
from sglang.multimodal_gen.runtime.entrypoints.utils import prepare_request
except ImportError:
print(
"Error: sglang.multimodal_gen is not installed. Please install it using 'pip install sglang[diffusion]'"
)
from .base import SGLDiffusionExecutor
class FluxExecutor(SGLDiffusionExecutor):
"""Executor for Flux models in ComfyUI."""
def __init__(self, generator, model_path, model, config):
super().__init__(generator, model_path, model, config)
def forward(self, x, timestep, context, y=None, guidance=None, **kwargs):
"""Forward pass for Flux model."""
hidden_states = self._pack_latents(x)
timesteps = timestep * 1000.0
encoder_hidden_states = context
pooled_projections = y
guidance = guidance * 1000.0
B, C, H, W = x.shape
height = H * 8
width = W * 8
# Create SamplingParams
sampling_params = SamplingParams.from_user_sampling_params_args(
self.model_path,
server_args=self.generator.server_args,
prompt=" ",
guidance_scale=3.5, # Flux typically uses embedded_cfg_scale=3.5
height=height,
width=width,
num_frames=1,
num_inference_steps=1,
save_output=False,
suppress_logs=self.should_suppress_logs(timestep),
)
# Prepare request (converts SamplingParams to Req)
req = prepare_request(
server_args=self.generator.server_args,
sampling_params=sampling_params,
)
req.latents = hidden_states # Set as [B, S, D] format directly
req.timesteps = timesteps # ComfyUI's timesteps parameter
req.prompt_embeds = [pooled_projections, encoder_hidden_states] # [CLIP, T5]
req.raw_latent_shape = torch.tensor(hidden_states.shape, dtype=torch.long)
# Set pooled_projections (required by Flux)
req.pooled_embeds = [pooled_projections] # List format as per Req definition
req.do_classifier_free_guidance = False
req.generator = [
torch.Generator("cuda") for _ in range(req.num_outputs_per_prompt)
]
# Send request to scheduler
output_batch = self.generator._send_to_scheduler_and_wait_for_response([req])
noise_pred = output_batch.noise_pred
return self._unpack_latents(noise_pred, H, W, C).to(x.device)
@@ -0,0 +1,172 @@
"""
QwenImage executor for SGLang Diffusion ComfyUI integration.
"""
import torch
try:
from sglang.multimodal_gen.configs.sample.sampling_params import SamplingParams
from sglang.multimodal_gen.runtime.entrypoints.utils import prepare_request
except ImportError:
print(
"Error: sglang.multimodal_gen is not installed. Please install it using 'pip install sglang[diffusion]'"
)
import comfy.ldm.common_dit
from .base import SGLDiffusionExecutor
class QwenImageExecutor(SGLDiffusionExecutor):
"""Executor for QwenImage models in ComfyUI."""
def __init__(self, generator, model_path, model, config):
super().__init__(generator, model_path, model, config)
self.patch_size = 2
def _pack_latents(self, x):
"""Process hidden states for QwenImage model."""
latents = comfy.ldm.common_dit.pad_to_patch_size(
x, (1, self.patch_size, self.patch_size)
)
orig_shape = latents.shape
latents = latents.view(
orig_shape[0],
orig_shape[1],
orig_shape[-3],
orig_shape[-2] // 2,
2,
orig_shape[-1] // 2,
2,
)
latents = latents.permute(0, 2, 3, 5, 1, 4, 6)
latents = latents.reshape(
orig_shape[0],
orig_shape[-3] * (orig_shape[-2] // 2) * (orig_shape[-1] // 2),
orig_shape[1] * 4,
)
return latents, orig_shape
def _unpack_latents(self, latents, num_embeds, orig_shape, x):
"""Unpack hidden states from packed format to standard format."""
latents = latents[:, :num_embeds].view(
orig_shape[0],
orig_shape[-3],
orig_shape[-2] // 2,
orig_shape[-1] // 2,
orig_shape[1],
2,
2,
)
latents = latents.permute(0, 4, 1, 2, 5, 3, 6)
latents = latents.reshape(orig_shape)[:, :, :, : x.shape[-2], : x.shape[-1]]
return latents
def forward(self, x, timestep, context, **kwargs):
"""Forward pass for QwenImage model."""
latents, orig_shape = self._pack_latents(x)
num_embeds = latents.shape[1]
height = orig_shape[-2] * 8
width = orig_shape[-1] * 8
sampling_params = SamplingParams.from_user_sampling_params_args(
self.model_path,
server_args=self.generator.server_args,
prompt=" ",
guidance_scale=1.0,
height=height,
width=width,
num_frames=1,
num_inference_steps=1,
save_output=False,
suppress_logs=self.should_suppress_logs(timestep),
)
# Prepare request (converts SamplingParams to Req)
req = prepare_request(
server_args=self.generator.server_args,
sampling_params=sampling_params,
)
# Set ComfyUI-specific inputs directly on the Req object
req.latents = latents
req.timesteps = timestep * 1000.0
req.prompt_embeds = [context]
req.raw_latent_shape = torch.tensor(latents.shape, dtype=torch.long)
req.do_classifier_free_guidance = False
req.generator = [
torch.Generator("cuda") for _ in range(req.num_outputs_per_prompt)
]
output_batch = self.generator._send_to_scheduler_and_wait_for_response([req])
noise_pred = output_batch.noise_pred
return self._unpack_latents(noise_pred, num_embeds, orig_shape, x)
class QwenImageEditExecutor(QwenImageExecutor):
"""Executor for QwenImageEdit models in ComfyUI."""
def __init__(self, generator, model_path, model, config):
super().__init__(generator, model_path, model, config)
def forward(
self,
x,
timestep,
context,
attention_mask=None,
ref_latents=None,
additional_t_cond=None,
transformer_options={},
**kwargs,
):
"""Forward pass for QwenImageEdit model."""
latents, orig_shape = self._pack_latents(x)
num_embeds = latents.shape[1]
height = orig_shape[-2] * 8
width = orig_shape[-1] * 8
# Prepare vae_image_sizes for the condition image (ref_latents)
vae_image_sizes = []
pack_ref_latents = None
# TODO: sgld now don't support multiple condition images, so we only support one condition image for now.
if ref_latents is not None and len(ref_latents) > 0:
pack_ref_latents, orig_ref_shape = self._pack_latents(ref_latents[0])
vae_image_sizes = [(orig_ref_shape[-1], orig_ref_shape[-2])]
sampling_params = SamplingParams.from_user_sampling_params_args(
self.model_path,
server_args=self.generator.server_args,
prompt=" ",
guidance_scale=1.0,
image_path="",
height=height,
width=width,
num_frames=1,
num_inference_steps=1,
save_output=False,
suppress_logs=self.should_suppress_logs(timestep),
)
# Prepare request (converts SamplingParams to Req)
req = prepare_request(
server_args=self.generator.server_args,
sampling_params=sampling_params,
)
# Set ComfyUI-specific inputs directly on the Req object
req.latents = latents
req.image_latent = pack_ref_latents
req.timesteps = timestep * 1000.0
req.vae_image_sizes = vae_image_sizes
req.prompt_embeds = [context]
req.raw_latent_shape = torch.tensor(latents.shape, dtype=torch.long)
req.do_classifier_free_guidance = False
req.generator = [
torch.Generator("cuda") for _ in range(req.num_outputs_per_prompt)
]
output_batch = self.generator._send_to_scheduler_and_wait_for_response([req])
noise_pred = output_batch.noise_pred
return self._unpack_latents(noise_pred, num_embeds, orig_shape, x)
@@ -0,0 +1,64 @@
"""
ZImage executor for SGLang Diffusion ComfyUI integration.
"""
import torch
try:
from sglang.multimodal_gen.configs.sample.sampling_params import SamplingParams
from sglang.multimodal_gen.runtime.entrypoints.utils import prepare_request
except ImportError:
print(
"Error: sglang.multimodal_gen is not installed. Please install it using 'pip install sglang[diffusion]'"
)
from .base import SGLDiffusionExecutor
class ZImageExecutor(SGLDiffusionExecutor):
"""Executor for ZImage models in ComfyUI."""
def __init__(self, generator, model_path, model, config):
super().__init__(generator, model_path, model, config)
def forward(self, x, timesteps, context, **kwargs):
"""Forward pass for ZImage model."""
B, C, H, W = x.shape
height = H * 8
width = W * 8
sampling_params = SamplingParams.from_user_sampling_params_args(
self.model_path,
server_args=self.generator.server_args,
prompt=" ",
guidance_scale=1.0,
height=height,
width=width,
num_frames=1, # For images
num_inference_steps=1, # Single step for ComfyUI
save_output=False,
suppress_logs=self.should_suppress_logs(timesteps),
)
# Prepare request (converts SamplingParams to Req)
req = prepare_request(
server_args=self.generator.server_args,
sampling_params=sampling_params,
)
latents = x.unsqueeze(2)
context = context.squeeze(0)
# Set ComfyUI-specific inputs directly on the Req object
req.latents = latents # ComfyUI's x parameter
req.timesteps = timesteps * 1000.0 # ComfyUI's timesteps parameter
req.prompt_embeds = [
context
] # ComfyUI's context parameter (must be List[Tensor])
req.raw_latent_shape = torch.tensor(latents.shape, dtype=torch.long)
req.do_classifier_free_guidance = False
req.generator = [
torch.Generator("cuda") for _ in range(req.num_outputs_per_prompt)
]
output_batch = self.generator._send_to_scheduler_and_wait_for_response([req])
noise_pred = output_batch.noise_pred
return noise_pred.permute(1, 0, 2, 3).to(x.device)
@@ -0,0 +1,715 @@
"""
ComfyUI nodes for SGLang Diffusion integration.
Provides nodes for connecting to SGLang Diffusion server and generating images/videos.
"""
import os
import uuid
import folder_paths
import torch
from .core import SGLDiffusionGenerator, SGLDiffusionServerAPI
from .utils import (
convert_b64_to_tensor_image,
convert_video_to_comfy_video,
get_image_path,
is_empty_image,
)
class SGLDOptions:
@classmethod
def INPUT_TYPES(cls):
return {
"required": {},
"optional": {
"model_type": (
["auto-detect", "qwen_image", "qwen_image_edit", "flux", "lumina2"],
{"default": "auto-detect"},
),
"enable_torch_compile": (
"BOOLEAN",
{"default": False},
),
"num_gpus": ("INT", {"default": 1, "min": 1, "step": 1}),
"tp_size": ("INT", {"default": -1, "min": -1, "step": 1}),
"sp_degree": ("INT", {"default": -1, "min": -1, "step": 1}),
"ulysses_degree": (
"INT",
{
"default": -1,
"min": -1,
"step": 1,
},
),
"ring_degree": (
"INT",
{
"default": -1,
"min": -1,
"step": 1,
},
),
"dp_size": ("INT", {"default": 1, "min": 1, "step": 1}),
"dp_degree": ("INT", {"default": 1, "min": 1, "step": 1}),
"enable_cfg_parallel": (
"BOOLEAN",
{"default": False},
),
"attention_backend": (
"STRING",
{"default": ""},
),
},
}
RETURN_TYPES = ("SGLD_OPTIONS",)
RETURN_NAMES = ("sgld_options",)
FUNCTION = "create_options"
CATEGORY = "SGLDiffusion"
def create_options(
self,
model_type: str = "auto-detect",
enable_torch_compile: bool = False,
num_gpus: int = 1,
tp_size: int = -1,
sp_degree: int = -1,
ulysses_degree: int = -1,
ring_degree: int = -1,
dp_size: int = 1,
dp_degree: int = 1,
enable_cfg_parallel: bool = False,
attention_backend: str = "",
):
"""
Build a dictionary of SGLang Diffusion runtime options.
"""
# Convert -1 to None for optional parameters (matching ServerArgs defaults)
ulysses_degree = None if ulysses_degree == -1 else ulysses_degree
ring_degree = None if ring_degree == -1 else ring_degree
attention_backend = None if attention_backend == "" else attention_backend
options = {
"model_type": model_type,
"enable_torch_compile": enable_torch_compile,
"num_gpus": num_gpus,
"tp_size": tp_size,
"sp_degree": sp_degree,
"ulysses_degree": ulysses_degree,
"ring_degree": ring_degree,
"dp_size": dp_size,
"dp_degree": dp_degree,
"enable_cfg_parallel": enable_cfg_parallel,
"attention_backend": attention_backend,
}
# Strip None to keep payload clean
options = {k: v for k, v in options.items() if v is not None}
return (options,)
class SGLDLoraLoader:
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"model": ("MODEL",),
"lora_name": (folder_paths.get_filename_list("loras"),),
"strength_model": (
"FLOAT",
{"default": 1.0, "min": 0, "max": 10, "step": 0.01},
),
"nickname": ("STRING", {"default": ""}),
"target": (
["all", "transformer", "transformer_2", "critic"],
{"default": "all"},
),
},
}
RETURN_TYPES = ("MODEL",)
FUNCTION = "load_lora"
CATEGORY = "SGLDiffusion"
def load_lora(
self, model, lora_name, strength_model=1.0, nickname="", target="all"
):
"""Load LoRA adapter using SGLang Diffusion API."""
lora_path = folder_paths.get_full_path("loras", lora_name)
assert model is not None
bi = model.clone()
nickname = nickname if nickname != "" else str("lora" + str(uuid.uuid4()))
# set lora in the model
bi.patches[nickname] = (lora_path, strength_model, target)
# prepare input for the SGLang Diffusion API
lora_input = {
"lora_nickname": [],
"lora_path": [],
"strength": [],
"target": [],
}
for nickname, lora_info in bi.patches.items():
lora_input["lora_nickname"].append(nickname)
lora_input["lora_path"].append(lora_info[0])
lora_input["strength"].append(lora_info[1])
lora_input["target"].append(lora_info[2])
# call the SGLang Diffusion API
model.model.diffusion_model.set_lora(**lora_input)
return (model,)
class SGLDUNETLoader:
def __init__(self):
self.generator = SGLDiffusionGenerator()
@classmethod
def INPUT_TYPES(s):
return {
"required": {
"unet_name": (folder_paths.get_filename_list("diffusion_models"),),
"weight_dtype": (["default", "fp8_e4m3fn", "fp8_e5m2"],),
},
"optional": {
"sgld_options": ("SGLD_OPTIONS",),
},
}
RETURN_TYPES = ("MODEL",)
FUNCTION = "load_unet"
CATEGORY = "SGLDiffusion"
def load_unet(self, unet_name, weight_dtype, sgld_options: dict = None):
model_options = {}
if weight_dtype == "fp8_e4m3fn":
model_options["dtype"] = torch.float8_e4m3fn
elif weight_dtype == "fp8_e5m2":
model_options["dtype"] = torch.float8_e5m2
unet_path = folder_paths.get_full_path("diffusion_models", unet_name)
model = self.generator.load_model(
unet_path, model_options=model_options, sgld_options=sgld_options
)
return (model,)
class SGLDiffusionServerModel:
"""Node to load and manage SGLang Diffusion server connection."""
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"base_url": (
"STRING",
{
"default": "http://localhost:3000/v1",
"multiline": False,
},
),
"api_key": (
"STRING",
{
"default": "sk-proj-1234567890",
"multiline": False,
},
),
}
}
RETURN_TYPES = ("SGLD_CLIENT", "STRING")
RETURN_NAMES = ("sgld_client", "model_info")
FUNCTION = "load_server"
CATEGORY = "SGLDiffusion"
def load_server(self, base_url: str, api_key: str):
"""Initialize OpenAI client for SGLang Diffusion server."""
client = SGLDiffusionServerAPI(base_url=base_url, api_key=api_key)
try:
model_info = client.get_model_info()
# Format model_info as a readable string
info_lines = ["=== SGLDiffusion Model Info ==="]
for key, value in model_info.items():
info_lines.append(f"{key}: {value}")
model_info_str = "\n".join(info_lines)
except Exception as e:
model_info_str = f"Failed to get model info: {str(e)}"
return (client, model_info_str)
class SGLDiffusionGenerateImage:
"""Node to generate images using SGLang Diffusion."""
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"sgld_client": ("SGLD_CLIENT",),
"positive_prompt": (
"STRING",
{
"default": "",
"tooltip": "Text prompt for image generation",
},
),
},
"optional": {
"negative_prompt": (
"STRING",
{
"default": "",
"tooltip": "Negative prompt to avoid certain elements",
},
),
"image": (
"IMAGE",
{
"default": None,
"tooltip": "input image to use for editing",
},
),
"seed": (
"INT",
{
"default": 1024,
"min": -1,
"max": 2**32 - 1,
},
),
"steps": (
"INT",
{
"default": 6,
"min": 1,
"max": 100,
"step": 1,
},
),
"cfg": (
"FLOAT",
{
"default": 7.0,
"min": 1.0,
"max": 20.0,
"step": 0.1,
},
),
"width": (
"INT",
{
"default": 1024,
"min": 256,
"max": 4096,
"step": 64,
},
),
"height": (
"INT",
{
"default": 1024,
"min": 256,
"max": 4096,
"step": 64,
},
),
"enable_teacache": (
"BOOLEAN",
{
"default": False,
},
),
},
}
RETURN_TYPES = ("IMAGE",)
RETURN_NAMES = ("image",)
FUNCTION = "generate_image"
CATEGORY = "SGLDiffusion"
OUTPUT_NODE = False
def generate_image(
self,
sgld_client: SGLDiffusionServerAPI,
positive_prompt: str,
negative_prompt: str = "",
image: torch.Tensor = None,
seed: int = 1024,
steps: int = 6,
cfg: float = 7.0,
width: int = 1024,
height: int = 1024,
enable_teacache: bool = False,
):
"""Generate image using SGLang Diffusion API."""
if not positive_prompt:
raise ValueError("Prompt cannot be empty")
size = f"{width}x{height}"
# Prepare request parameters
request_params = {
"prompt": positive_prompt,
"size": size,
"response_format": "b64_json",
}
# Add optional parameters if provided
if negative_prompt:
request_params["negative_prompt"] = negative_prompt
if cfg is not None:
request_params["guidance_scale"] = cfg
if steps is not None:
request_params["num_inference_steps"] = steps
if seed is not None and seed >= 0:
request_params["seed"] = seed
if enable_teacache:
request_params["enable_teacache"] = True
if image is not None:
# If the image is empty, use the size of the image to generate the image
if is_empty_image(image):
width, height = image.shape[2], image.shape[1]
size = f"{width}x{height}"
request_params["size"] = size
else:
request_params["image_path"] = get_image_path(image)
# Call API
try:
response = sgld_client.generate_image(**request_params)
except Exception as e:
raise RuntimeError(f"Failed to generate image: {str(e)}")
# Decode base64 image
if not response["data"] or not response["data"][0]["b64_json"]:
raise RuntimeError("No image data in response")
image_data = response["data"][0]["b64_json"]
image = convert_b64_to_tensor_image(image_data)
return (image,)
class SGLDiffusionGenerateVideo:
"""Node to generate videos using SGLang Diffusion."""
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"sgld_client": ("SGLD_CLIENT",),
"positive_prompt": (
"STRING",
{
"default": "",
"tooltip": "Text prompt for video generation",
},
),
},
"optional": {
"negative_prompt": (
"STRING",
{
"default": "",
"tooltip": "Negative prompt to avoid certain elements",
},
),
"image": (
"IMAGE",
{
"default": None,
"tooltip": "input image to use for image-to-video",
},
),
"seed": (
"INT",
{
"default": 1024,
"min": -1,
"max": 2**32 - 1,
},
),
"steps": (
"INT",
{
"default": 6,
"min": 1,
"max": 100,
"step": 1,
},
),
"cfg": (
"FLOAT",
{
"default": 7.0,
"min": 1.0,
"max": 20.0,
"step": 0.1,
},
),
"width": (
"INT",
{
"default": 1280,
"min": 256,
"max": 4096,
"step": 1,
},
),
"height": (
"INT",
{
"default": 720,
"min": 256,
"max": 4096,
"step": 1,
},
),
"num_frames": (
"INT",
{
"default": 120,
"min": 1,
"max": 1000,
"step": 1,
},
),
"fps": (
"INT",
{
"default": 24,
"min": 1,
"max": 60,
"step": 1,
},
),
"seconds": (
"INT",
{
"default": 5,
"min": 1,
"max": 60,
"step": 1,
},
),
"enable_teacache": (
"BOOLEAN",
{
"default": False,
},
),
},
}
RETURN_TYPES = ("VIDEO", "STRING")
RETURN_NAMES = ("video", "video_path")
FUNCTION = "generate_video"
CATEGORY = "SGLDiffusion"
OUTPUT_NODE = False
def generate_video(
self,
sgld_client: SGLDiffusionServerAPI,
positive_prompt: str,
negative_prompt: str = "",
image: torch.Tensor = None,
seed: int = 1024,
steps: int = 6,
cfg: float = 7.0,
width: int = 1280,
height: int = 720,
num_frames: int = 120,
fps: int = 24,
seconds: int = 5,
enable_teacache: bool = False,
):
"""Generate video using SGLang Diffusion API."""
if not positive_prompt:
raise ValueError("Prompt cannot be empty")
size = f"{width}x{height}"
output_dir = folder_paths.get_temp_directory()
# Prepare request parameters
request_params = {
"prompt": positive_prompt,
"size": size,
"seconds": seconds,
"fps": fps,
"output_path": output_dir,
}
# Add optional parameters if provided
if negative_prompt:
request_params["negative_prompt"] = negative_prompt
if cfg is not None:
request_params["guidance_scale"] = cfg
if steps is not None:
request_params["num_inference_steps"] = steps
if seed is not None and seed >= 0:
request_params["seed"] = seed
if enable_teacache:
request_params["enable_teacache"] = True
if num_frames is not None:
request_params["num_frames"] = num_frames
if image is not None:
# If the image is empty, use the size of the image to generate the video
if is_empty_image(image):
width, height = image.shape[2], image.shape[1]
size = f"{width}x{height}"
request_params["size"] = size
else:
request_params["input_reference"] = get_image_path(image)
# Call API
try:
response = sgld_client.generate_video(**request_params)
video_path = response.get("file_path", "")
video = convert_video_to_comfy_video(video_path, height, width)
except Exception as e:
raise RuntimeError(f"Failed to generate video: {str(e)}")
return (video, video_path)
class SGLDiffusionServerSetLora:
"""Node to set LoRA adapter for SGLang Diffusion server."""
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"sgld_client": ("SGLD_CLIENT",),
"lora_name": (
"STRING",
{
"default": "",
"tooltip": "The name of the LoRA adapter",
},
),
},
"optional": {
"lora_nickname": (
"STRING",
{
"default": "",
"tooltip": "The nickname of the LoRA adapter",
},
),
"target": (
[
"all",
"transformer",
"transformer_2",
"critic",
],
{
"default": "all",
"tooltip": "Which transformer(s) to apply the LoRA to",
},
),
},
}
RETURN_TYPES = ("SGLD_CLIENT",)
RETURN_NAMES = ("sgld_client",)
FUNCTION = "set_lora"
CATEGORY = "SGLDiffusion"
OUTPUT_NODE = False
def set_lora(
self,
sgld_client: SGLDiffusionServerAPI,
lora_name: str = "",
lora_nickname: str = "",
target: str = "all",
):
"""Set LoRA adapter using SGLang Diffusion API."""
if lora_nickname == "":
lora_nickname = os.path.splitext(lora_name)[0]
# Prepare request parameters
request_params = {
"lora_nickname": lora_nickname,
"lora_path": lora_name,
"target": target,
}
# Call API
try:
sgld_client.set_lora(**request_params)
return (sgld_client,)
except Exception as e:
raise RuntimeError(f"Failed to set LoRA adapter: {str(e)}")
class SGLDiffusionServerUnsetLora:
"""Node to unset LoRA adapter for SGLang Diffusion server."""
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"sgld_client": ("SGLD_CLIENT",),
},
"optional": {
"target": (
[
"all",
"transformer",
"transformer_2",
"critic",
],
{
"default": "all",
"tooltip": "Which transformer(s) to unset the LoRA from",
},
),
},
}
RETURN_TYPES = ("SGLD_CLIENT",)
RETURN_NAMES = ("sgld_client",)
FUNCTION = "unset_lora"
CATEGORY = "SGLDiffusion"
OUTPUT_NODE = False
def unset_lora(
self,
sgld_client: SGLDiffusionServerAPI,
target: str = "all",
):
"""Unset LoRA adapter using SGLang Diffusion API."""
try:
sgld_client.unset_lora(target=target)
return (sgld_client,)
except Exception as e:
raise RuntimeError(f"Failed to unset LoRA adapter: {str(e)}")
# Register nodes
NODE_CLASS_MAPPINGS = {
"SGLDiffusionServerModel": SGLDiffusionServerModel,
"SGLDiffusionGenerateImage": SGLDiffusionGenerateImage,
"SGLDiffusionGenerateVideo": SGLDiffusionGenerateVideo,
"SGLDiffusionServerSetLora": SGLDiffusionServerSetLora,
"SGLDiffusionServerUnsetLora": SGLDiffusionServerUnsetLora,
"SGLDUNETLoader": SGLDUNETLoader,
"SGLDOptions": SGLDOptions,
"SGLDLoraLoader": SGLDLoraLoader,
}
NODE_DISPLAY_NAME_MAPPINGS = {
"SGLDiffusionServerModel": "SGLDiffusion Server Model",
"SGLDiffusionGenerateImage": "SGLDiffusion Generate Image",
"SGLDiffusionGenerateVideo": "SGLDiffusion Generate Video",
"SGLDiffusionServerSetLora": "SGLDiffusion Server Set LoRA",
"SGLDiffusionServerUnsetLora": "SGLDiffusion Server Unset LoRA",
"SGLDUNETLoader": "SGLDiffusion UNET Loader",
"SGLDOptions": "SGLDiffusion Options",
"SGLDLoraLoader": "SGLDiffusion LoRA Loader",
}
@@ -0,0 +1,66 @@
# ComfyUI SGLDiffusion Pipeline Tests
This directory contains tests for each ComfyUI pipeline integration.
## Test Files
- `test_zimage_pipeline.py` - Tests for ComfyUIZImagePipeline
- `test_flux_pipeline.py` - Tests for ComfyUIFluxPipeline
- `test_qwen_image_pipeline.py` - Tests for ComfyUIQwenImagePipeline
- `test_qwen_image_edit_pipeline.py` - Tests for ComfyUIQwenImageEditPipeline (I2I/edit mode)
## Running Tests
### Run all tests
```bash
pytest python/sglang/multimodal_gen/apps/ComfyUI_SGLDiffusion/test/ -v -s
```
### Run a specific test file
```bash
pytest python/sglang/multimodal_gen/apps/ComfyUI_SGLDiffusion/test/test_zimage_pipeline.py -v -s
```
## Environment Variables
You can configure model paths via environment variables. Model paths support two formats:
- **Safetensors file**: Path to a single `.safetensors` file (e.g., `/path/to/model.safetensors`)
- **Diffusers format**: HuggingFace model ID or local diffusers directory (e.g., `Tongyi-MAI/Z-Image-Turbo`)
Environment variables:
- `SGLANG_TEST_ZIMAGE_MODEL_PATH` - Path to ZImage model (default: `Tongyi-MAI/Z-Image-Turbo`)
- `SGLANG_TEST_FLUX_MODEL_PATH` - Path to Flux model (default: `black-forest-labs/FLUX.1-dev`)
- `SGLANG_TEST_QWEN_IMAGE_MODEL_PATH` - Path to QwenImage model (default: `Qwen/Qwen-Image`)
- `SGLANG_TEST_QWEN_IMAGE_EDIT_MODEL_PATH` - Path to QwenImageEdit model (default: `Qwen/Qwen-Image-Edit-2511`)
Examples:
```bash
# Using HuggingFace model ID (diffusers format)
export SGLANG_TEST_ZIMAGE_MODEL_PATH="Tongyi-MAI/Z-Image-Turbo"
pytest python/sglang/multimodal_gen/apps/ComfyUI_SGLDiffusion/test/test_zimage_pipeline.py -v -s
# Using safetensors file
export SGLANG_TEST_ZIMAGE_MODEL_PATH="/path/to/z_image_turbo_bf16.safetensors"
pytest python/sglang/multimodal_gen/apps/ComfyUI_SGLDiffusion/test/test_zimage_pipeline.py -v -s
```
## Test Structure
Each test file follows a similar structure:
1. **Setup**: Creates a `DiffGenerator` with the appropriate pipeline class
2. **Input Preparation**: Creates dummy tensors for latents, timesteps, and embeddings
3. **Request Preparation**: Uses `prepare_request` to convert `SamplingParams` to `Req`
4. **ComfyUI Inputs**: Sets ComfyUI-specific inputs directly on the `Req` object
5. **Execution**: Sends request to scheduler and waits for response
6. **Validation**: Checks that `noise_pred` is retrieved from `OutputBatch`
## Notes
- These tests use `comfyui_mode=True` to enable ComfyUI-specific behavior
- Tests use pre-processed inputs (latents, timesteps, embeddings) as ComfyUI would provide
- The tests verify that `noise_pred` can be retrieved from the `OutputBatch` after processing
- All tests use dummy/ones tensors for simplicity - in production, these would be actual model outputs
@@ -0,0 +1,9 @@
"""
Test suite for ComfyUI SGLDiffusion pipelines.
This package contains tests for each ComfyUI pipeline integration:
- ZImagePipeline
- FluxPipeline
- QwenImagePipeline
- QwenImageEditPipeline
"""
@@ -0,0 +1,156 @@
"""Test for ComfyUIFluxPipeline with pass-through scheduler."""
import os
import sys
import pytest
import torch
from sglang.multimodal_gen.configs.sample.sampling_params import SamplingParams
from sglang.multimodal_gen.runtime.entrypoints.diffusion_generator import DiffGenerator
from sglang.multimodal_gen.runtime.entrypoints.utils import prepare_request
def test_comfyui_flux_pipeline_direct() -> None:
"""Test ComfyUIFluxPipeline with custom inputs."""
model_path = os.environ.get(
"SGLANG_TEST_FLUX_MODEL_PATH",
"black-forest-labs/FLUX.1-dev", # Supports both safetensors file and diffusers format
)
generator = DiffGenerator.from_pretrained(
model_path=model_path,
pipeline_class_name="ComfyUIFluxPipeline",
num_gpus=2,
comfyui_mode=True,
)
batch_size = 1
hidden_states_seq_len = 3600
hidden_states_dim = 64
height = 1280
width = 720
encoder_seq_len = 512
encoder_dim = 4096
pooled_dim = 768
hidden_states = torch.ones(
batch_size,
hidden_states_seq_len,
hidden_states_dim,
device="cuda",
dtype=torch.bfloat16,
)
encoder_hidden_states = torch.ones(
batch_size,
encoder_seq_len,
encoder_dim,
device="cuda",
dtype=torch.bfloat16,
)
pooled_projections = torch.ones(
batch_size,
pooled_dim,
device="cuda",
dtype=torch.bfloat16,
)
timesteps = torch.tensor([1000], dtype=torch.long, device="cuda")
sampling_params = SamplingParams.from_user_sampling_params_args(
generator.server_args.model_path,
server_args=generator.server_args,
prompt="a beautiful girl",
height=height,
width=width,
num_frames=1,
num_inference_steps=1,
save_output=True,
return_trajectory_latents=True,
)
req = prepare_request(
server_args=generator.server_args,
sampling_params=sampling_params,
)
req.latents = hidden_states
req.timesteps = timesteps
req.raw_latent_shape = torch.tensor(hidden_states.shape, dtype=torch.long)
clip_dim = 768
req.prompt_embeds = [pooled_projections, encoder_hidden_states]
if req.guidance_scale > 1.0:
dummy_neg_clip_embedding = torch.zeros(
batch_size,
77,
clip_dim,
device="cuda",
dtype=torch.bfloat16,
)
negative_encoder_hidden_states = torch.ones(
batch_size,
encoder_seq_len,
encoder_dim,
device="cuda",
dtype=torch.bfloat16,
)
req.negative_prompt_embeds = [
dummy_neg_clip_embedding,
negative_encoder_hidden_states,
]
else:
req.negative_prompt_embeds = None
req.pooled_embeds = [pooled_projections]
req.neg_pooled_embeds = []
if (
req.guidance_scale > 1.0
and req.negative_prompt_embeds is not None
and len(req.negative_prompt_embeds) > 0
):
req.do_classifier_free_guidance = True
else:
req.do_classifier_free_guidance = False
if req.seed is not None:
generator_device = req.generator_device
device_str = "cuda" if generator_device == "cuda" else "cpu"
req.generator = [
torch.Generator(device_str).manual_seed(req.seed + i)
for i in range(req.num_outputs_per_prompt)
]
else:
req.generator = [
torch.Generator("cuda") for _ in range(req.num_outputs_per_prompt)
]
output_batch = generator._send_to_scheduler_and_wait_for_response([req])
noise_pred = output_batch.noise_pred
assert noise_pred is not None, "noise_pred should not be None in OutputBatch"
assert isinstance(noise_pred, torch.Tensor), "noise_pred should be a torch.Tensor"
assert (
noise_pred.device.type == "cuda"
), f"noise_pred should be on cuda, got {noise_pred.device}"
assert (
noise_pred.dtype == torch.bfloat16
), f"noise_pred should be bfloat16, got {noise_pred.dtype}"
print("✓ Successfully retrieved noise_pred from OutputBatch!")
print(f" noise_pred shape: {noise_pred.shape}")
print(f" noise_pred dtype: {noise_pred.dtype}")
print(f" noise_pred device: {noise_pred.device}")
latents = output_batch.output if output_batch.output is not None else req.latents
assert latents is not None, "latents should not be None"
print(f"latents.shape: {latents.shape}")
if __name__ == "__main__":
sys.exit(pytest.main([__file__, "-v"]))
@@ -0,0 +1,136 @@
"""Test for ComfyUIQwenImageEditPipeline with pass-through scheduler (I2I/edit mode)."""
import os
import sys
import pytest
import torch
from sglang.multimodal_gen.configs.sample.sampling_params import SamplingParams
from sglang.multimodal_gen.runtime.entrypoints.diffusion_generator import DiffGenerator
from sglang.multimodal_gen.runtime.entrypoints.utils import prepare_request
def test_comfyui_qwen_image_edit_pipeline_direct() -> None:
"""Test ComfyUIQwenImageEditPipeline with edit mode (I2I) and custom inputs."""
model_path = os.environ.get(
"SGLANG_TEST_QWEN_IMAGE_EDIT_MODEL_PATH",
"Qwen/Qwen-Image-Edit-2511", # Supports both safetensors file and diffusers format
)
generator = DiffGenerator.from_pretrained(
model_path=model_path,
pipeline_class_name="ComfyUIQwenImageEditPipeline",
num_gpus=1,
comfyui_mode=True,
dit_layerwise_offload=False,
)
batch_size = 1
noisy_image_seq_len = 3600
hidden_states_dim = 64
condition_image_seq_len = 6889
condition_image_dim = 64
encoder_seq_len = 45
encoder_dim = 3584
height = 720
width = 1280
vae_scale_factor = 8
condition_height_latent = 1328 // vae_scale_factor
condition_width_latent = 1328 // vae_scale_factor
noisy_image_latents = torch.ones(
batch_size,
noisy_image_seq_len,
hidden_states_dim,
device="cuda",
dtype=torch.bfloat16,
)
condition_image_latents = torch.ones(
batch_size,
condition_image_seq_len,
condition_image_dim,
device="cuda",
dtype=torch.bfloat16,
)
encoder_hidden_states = torch.ones(
batch_size,
encoder_seq_len,
encoder_dim,
device="cuda",
dtype=torch.bfloat16,
)
timesteps = torch.tensor([1000], dtype=torch.long, device="cuda")
sampling_params = SamplingParams.from_user_sampling_params_args(
generator.server_args.model_path,
server_args=generator.server_args,
prompt=" ",
guidance_scale=1.0,
height=height,
width=width,
image_path="",
num_frames=1,
num_inference_steps=1,
seed=42,
save_output=False,
return_frames=False,
)
req = prepare_request(
server_args=generator.server_args,
sampling_params=sampling_params,
)
req.latents = noisy_image_latents
req.image_latent = condition_image_latents
req.timesteps = timesteps
req.prompt_embeds = [encoder_hidden_states]
req.negative_prompt_embeds = None
req.vae_image_sizes = [(condition_width_latent, condition_height_latent)]
req.raw_latent_shape = torch.tensor(noisy_image_latents.shape, dtype=torch.long)
if req.guidance_scale > 1.0 and req.negative_prompt_embeds is not None:
req.do_classifier_free_guidance = True
else:
req.do_classifier_free_guidance = False
if req.seed is not None:
generator_device = req.generator_device
device_str = "cpu" if generator_device == "cpu" else "cuda"
req.generator = [
torch.Generator(device_str).manual_seed(req.seed + i)
for i in range(req.num_outputs_per_prompt)
]
else:
req.generator = [
torch.Generator("cuda") for _ in range(req.num_outputs_per_prompt)
]
output_batch = generator._send_to_scheduler_and_wait_for_response([req])
noise_pred = output_batch.noise_pred
assert noise_pred is not None, "noise_pred should not be None in OutputBatch"
assert isinstance(noise_pred, torch.Tensor), "noise_pred should be a torch.Tensor"
assert (
noise_pred.device.type == "cuda"
), f"noise_pred should be on cuda, got {noise_pred.device}"
assert (
noise_pred.dtype == torch.bfloat16
), f"noise_pred should be bfloat16, got {noise_pred.dtype}"
print("✓ Successfully retrieved noise_pred from OutputBatch (Edit Mode)!")
print(f" noise_pred shape: {noise_pred.shape}")
print(f" noise_pred dtype: {noise_pred.dtype}")
print(f" noise_pred device: {noise_pred.device}")
latents = output_batch.output if output_batch.output is not None else req.latents
assert latents is not None, "latents should not be None"
if __name__ == "__main__":
sys.exit(pytest.main([__file__, "-v"]))
@@ -0,0 +1,120 @@
"""Test for ComfyUIQwenImagePipeline with pass-through scheduler."""
import os
import sys
import pytest
import torch
from sglang.multimodal_gen.configs.sample.sampling_params import SamplingParams
from sglang.multimodal_gen.runtime.entrypoints.diffusion_generator import DiffGenerator
from sglang.multimodal_gen.runtime.entrypoints.utils import prepare_request
def test_comfyui_qwen_image_pipeline_direct() -> None:
"""Test ComfyUIQwenImagePipeline with custom inputs."""
model_path = os.environ.get(
"SGLANG_TEST_QWEN_IMAGE_MODEL_PATH",
"Qwen/Qwen-Image", # Supports both safetensors file and diffusers format
)
generator = DiffGenerator.from_pretrained(
model_path=model_path,
pipeline_class_name="ComfyUIQwenImagePipeline",
num_gpus=2,
comfyui_mode=True,
dit_layerwise_offload=False,
)
batch_size = 1
hidden_states_seq_len = 6889
hidden_states_dim = 64
encoder_seq_len = 45
encoder_dim = 3584
height = 1328
width = 1328
dtype = torch.bfloat16
hidden_states = torch.ones(
batch_size,
hidden_states_seq_len,
hidden_states_dim,
device="cuda",
dtype=dtype,
)
encoder_hidden_states = torch.ones(
batch_size,
encoder_seq_len,
encoder_dim,
device="cuda",
dtype=torch.bfloat16,
)
timesteps = torch.tensor([1000], dtype=torch.long, device="cuda")
sampling_params = SamplingParams.from_user_sampling_params_args(
generator.server_args.model_path,
server_args=generator.server_args,
prompt=" ",
guidance_scale=3.0,
height=height,
width=width,
num_frames=1,
num_inference_steps=1,
seed=42,
save_output=False,
return_frames=False,
)
req = prepare_request(
server_args=generator.server_args,
sampling_params=sampling_params,
)
req.latents = hidden_states
req.timesteps = timesteps
req.prompt_embeds = [encoder_hidden_states]
req.negative_prompt_embeds = [encoder_hidden_states]
req.raw_latent_shape = torch.tensor(hidden_states.shape, dtype=torch.long)
if req.guidance_scale > 1.0 and req.negative_prompt_embeds is not None:
req.do_classifier_free_guidance = True
else:
req.do_classifier_free_guidance = False
if req.seed is not None:
generator_device = req.generator_device
device_str = "cpu" if generator_device == "cpu" else "cuda"
req.generator = [
torch.Generator(device_str).manual_seed(req.seed + i)
for i in range(req.num_outputs_per_prompt)
]
else:
req.generator = [
torch.Generator("cuda") for _ in range(req.num_outputs_per_prompt)
]
output_batch = generator._send_to_scheduler_and_wait_for_response([req])
noise_pred = output_batch.noise_pred
assert noise_pred is not None, "noise_pred should not be None in OutputBatch"
assert isinstance(noise_pred, torch.Tensor), "noise_pred should be a torch.Tensor"
assert (
noise_pred.device.type == "cuda"
), f"noise_pred should be on cuda, got {noise_pred.device}"
assert (
noise_pred.dtype == torch.bfloat16
), f"noise_pred should be bfloat16, got {noise_pred.dtype}"
print("✓ Successfully retrieved noise_pred from OutputBatch!")
print(f" noise_pred shape: {noise_pred.shape}")
print(f" noise_pred dtype: {noise_pred.dtype}")
print(f" noise_pred device: {noise_pred.device}")
latents = output_batch.output if output_batch.output is not None else req.latents
assert latents is not None, "latents should not be None"
if __name__ == "__main__":
sys.exit(pytest.main([__file__, "-v"]))
@@ -0,0 +1,122 @@
"""Test for ComfyUIZImagePipeline with pass-through scheduler."""
import os
import sys
import pytest
import torch
from sglang.multimodal_gen.configs.sample.sampling_params import SamplingParams
from sglang.multimodal_gen.runtime.entrypoints.diffusion_generator import DiffGenerator
from sglang.multimodal_gen.runtime.entrypoints.utils import prepare_request
def test_comfyui_zimage_pipeline_direct() -> None:
"""Test ComfyUIZImagePipeline with custom inputs."""
model_path = os.environ.get(
"SGLANG_TEST_ZIMAGE_MODEL_PATH",
"Tongyi-MAI/Z-Image-Turbo", # Supports both safetensors file and diffusers format
)
generator = DiffGenerator.from_pretrained(
model_path=model_path,
pipeline_class_name="ComfyUIZImagePipeline",
num_gpus=1,
sp_degree=1,
comfyui_mode=True,
)
batch_size = 1
num_channels = 16
num_frames = 1
height = 720
width = 1280
latent_height = height // 8
latent_width = width // 8
latents = torch.ones(
batch_size,
num_channels,
num_frames,
latent_height,
latent_width,
device="cuda",
dtype=torch.bfloat16,
)
timesteps = torch.tensor([1000], dtype=torch.long, device="cuda")
context_seq_len = 19
context_dim = 2560
context = torch.ones(
context_seq_len,
context_dim,
device="cuda",
dtype=torch.bfloat16,
)
sampling_params = SamplingParams.from_user_sampling_params_args(
generator.server_args.model_path,
server_args=generator.server_args,
prompt="a beautiful girl",
guidance_scale=1.0,
height=height,
width=width,
num_frames=1,
num_inference_steps=1,
seed=42,
save_output=False,
return_frames=False,
)
req = prepare_request(
server_args=generator.server_args,
sampling_params=sampling_params,
)
req.latents = latents
req.timesteps = timesteps
req.prompt_embeds = [context]
req.negative_prompt_embeds = None
req.raw_latent_shape = torch.tensor(latents.shape, dtype=torch.long)
if req.guidance_scale > 1.0 and req.negative_prompt_embeds is not None:
req.do_classifier_free_guidance = True
else:
req.do_classifier_free_guidance = False
if req.seed is not None:
generator_device = req.generator_device
device_str = "cpu" if generator_device == "cpu" else "cuda"
req.generator = [
torch.Generator(device_str).manual_seed(req.seed + i)
for i in range(req.num_outputs_per_prompt)
]
else:
req.generator = [
torch.Generator("cuda") for _ in range(req.num_outputs_per_prompt)
]
output_batch = generator._send_to_scheduler_and_wait_for_response([req])
noise_pred = output_batch.noise_pred
assert noise_pred is not None, "noise_pred should not be None in OutputBatch"
assert isinstance(noise_pred, torch.Tensor), "noise_pred should be a torch.Tensor"
assert (
noise_pred.device.type == "cuda"
), f"noise_pred should be on cuda, got {noise_pred.device}"
assert (
noise_pred.dtype == torch.bfloat16
), f"noise_pred should be bfloat16, got {noise_pred.dtype}"
print("✓ Successfully retrieved noise_pred from OutputBatch!")
print(f" noise_pred shape: {noise_pred.shape}")
print(f" noise_pred dtype: {noise_pred.dtype}")
print(f" noise_pred device: {noise_pred.device}")
latents = output_batch.output if output_batch.output is not None else req.latents
assert latents is not None, "latents should not be None"
if __name__ == "__main__":
sys.exit(pytest.main([__file__, "-v"]))
@@ -0,0 +1,176 @@
import base64
import io
import os
import shutil
import time
import uuid
import folder_paths
import numpy as np
import torch
from comfy_api.input import VideoInput
from PIL import Image
def _ensure_dir(path: str) -> None:
os.makedirs(path, exist_ok=True)
def _to_numpy_image(image: torch.Tensor) -> np.ndarray:
"""Convert ComfyUI image tensor to uint8 numpy array (H, W, C)."""
if image.dim() == 4:
image = image[0]
if image.dim() == 3 and image.shape[0] in (1, 3, 4):
image = image.permute(1, 2, 0)
elif image.dim() == 2:
image = image.unsqueeze(-1)
np_img = image.detach().cpu().numpy()
np_img = np.clip(np_img, 0.0, 1.0)
np_img = (np_img * 255).astype(np.uint8)
if np_img.shape[-1] == 1:
np_img = np.repeat(np_img, 3, axis=-1)
return np_img
def _to_hwc_tensor(image: torch.Tensor) -> torch.Tensor:
"""Convert ComfyUI image tensor to HWC format (normalized [0, 1])."""
img = image.clone()
if img.dim() == 4:
img = img[0]
if img.dim() == 3 and img.shape[0] in (1, 3, 4):
img = img.permute(1, 2, 0)
elif img.dim() == 2:
img = img.unsqueeze(-1)
img = torch.clamp(img, 0.0, 1.0)
if img.shape[-1] == 1:
img = img.repeat(1, 1, 3)
return img
def is_empty_image(image: torch.Tensor, tolerance: float = 1e-6) -> bool:
"""
Check if the input image is an empty/solid color image (like ComfyUI's empty image).
Args:
image: Input tensor image in ComfyUI format (BCHW, CHW, HWC, etc.)
tolerance: Tolerance for floating point comparison (default: 1e-6)
Returns:
True if the image is empty (all pixels have same color), False otherwise
"""
if image is None:
return True
# Convert to HWC format
img_hwc = _to_hwc_tensor(image)
# Get the first pixel's RGB values
first_pixel = img_hwc[0, 0, :]
h, w, c = img_hwc.shape
pixels = img_hwc.reshape(-1, c)
diff = torch.abs(pixels - first_pixel)
max_diff = torch.max(diff)
return max_diff.item() <= tolerance
def get_image_path(image: torch.Tensor) -> str:
"""
Save tensor image to ComfyUI temp directory as PNG and return the path.
"""
temp_dir = folder_paths.get_temp_directory()
# Build file name
ts = time.strftime("%Y%m%d-%H%M%S")
unique = uuid.uuid4().hex[:8]
file_name = f"sgl_output_{ts}_{unique}.png"
file_path = os.path.join(temp_dir, file_name)
# Save image
np_img = _to_numpy_image(image)
img = Image.fromarray(np_img)
img.save(file_path, format="PNG")
return file_path
def convert_b64_to_tensor_image(b64_image: str) -> torch.Tensor:
"""
Convert base64 encoded image to ComfyUI IMAGE format (torch.Tensor).
Args:
b64_image: Base64 encoded image string
Returns:
torch.Tensor with shape [batch_size, height, width, channels] (BHWC format),
values normalized to [0, 1] range, RGB format (3 channels)
"""
# Decode base64
image_bytes = base64.b64decode(b64_image)
# Open image and convert to RGB
pil_image = Image.open(io.BytesIO(image_bytes))
if pil_image.mode != "RGB":
pil_image = pil_image.convert("RGB")
# Convert to numpy array and normalize to [0, 1]
image_array = np.array(pil_image).astype(np.float32) / 255.0
# Add batch dimension: [height, width, channels] -> [1, height, width, channels]
image_array = image_array[np.newaxis, ...]
# Convert to torch.Tensor
tensor_image = torch.from_numpy(image_array)
return tensor_image
class SGLDVideoInput(VideoInput):
def __init__(self, video_path: str, height: int, width: int):
super().__init__()
self.video_path = video_path
self.height = height
self.width = width
def get_dimensions(self) -> tuple[int, int]:
"""
Returns the dimensions of the video input.
Returns:
Tuple of (width, height)
"""
return self.width, self.height
def get_components(self):
"""
Returns the components of the video input.
This is required by the VideoInput abstract base class.
"""
return [self.video_path]
def save_to(self, path: str, format=None, codec=None, metadata=None):
"""
Abstract method to save the video input to a file.
"""
save_path = path
# Copy video file from video_path to save_path
if os.path.exists(self.video_path):
# Ensure destination directory exists
save_dir = os.path.dirname(save_path)
if save_dir:
os.makedirs(save_dir, exist_ok=True)
shutil.copy2(self.video_path, save_path)
def convert_video_to_comfy_video(
video_path: str, height: int, width: int
) -> VideoInput:
"""
Convert video to ComfyUI VIDEO format (VideoInput).
"""
video_input = SGLDVideoInput(video_path, height, width)
return video_input
@@ -0,0 +1,222 @@
{
"8": {
"inputs": {
"samples": [
"40",
0
],
"vae": [
"10",
0
]
},
"class_type": "VAEDecode",
"_meta": {
"title": "VAE Decode"
}
},
"10": {
"inputs": {
"vae_name": "ae.safetensors"
},
"class_type": "VAELoader",
"_meta": {
"title": "Load VAE"
}
},
"11": {
"inputs": {
"clip_name1": "t5xxl_fp16.safetensors",
"clip_name2": "clip_l.safetensors",
"type": "flux",
"device": "default"
},
"class_type": "DualCLIPLoader",
"_meta": {
"title": "DualCLIPLoader"
}
},
"17": {
"inputs": {
"scheduler": "normal",
"steps": 25,
"denoise": 1,
"model": [
"46",
0
]
},
"class_type": "BasicScheduler",
"_meta": {
"title": "BasicScheduler"
}
},
"38": {
"inputs": {
"model": [
"46",
0
],
"conditioning": [
"42",
0
]
},
"class_type": "BasicGuider",
"_meta": {
"title": "BasicGuider"
}
},
"39": {
"inputs": {
"filename_prefix": "ComfyUI",
"images": [
"8",
0
]
},
"class_type": "SaveImage",
"_meta": {
"title": "Save Image"
}
},
"40": {
"inputs": {
"noise": [
"45",
0
],
"guider": [
"38",
0
],
"sampler": [
"47",
0
],
"sigmas": [
"17",
0
],
"latent_image": [
"44",
0
]
},
"class_type": "SamplerCustomAdvanced",
"_meta": {
"title": "SamplerCustomAdvanced"
}
},
"42": {
"inputs": {
"guidance": 3.5,
"conditioning": [
"43",
0
]
},
"class_type": "FluxGuidance",
"_meta": {
"title": "FluxGuidance"
}
},
"43": {
"inputs": {
"text": "beautiful photography of a gonger haired artist with Lots of Colorful coloursplashes in face and pn her hands, she is natural, having her hair in a casual bun, looking happily into camera, cinematic,",
"speak_and_recognation": {
"__value__": [
false,
true
]
},
"clip": [
"11",
0
]
},
"class_type": "CLIPTextEncode",
"_meta": {
"title": "CLIP Text Encode (Prompt)"
}
},
"44": {
"inputs": {
"width": 1024,
"height": 1024,
"batch_size": 1
},
"class_type": "EmptySD3LatentImage",
"_meta": {
"title": "EmptySD3LatentImage"
}
},
"45": {
"inputs": {
"noise_seed": 747172083610812
},
"class_type": "RandomNoise",
"_meta": {
"title": "RandomNoise"
}
},
"46": {
"inputs": {
"max_shift": 1.15,
"base_shift": 0.5,
"width": 1024,
"height": 1024,
"model": [
"51",
0
]
},
"class_type": "ModelSamplingFlux",
"_meta": {
"title": "ModelSamplingFlux"
}
},
"47": {
"inputs": {
"sampler_name": "euler"
},
"class_type": "KSamplerSelect",
"_meta": {
"title": "KSamplerSelect"
}
},
"51": {
"inputs": {
"unet_name": "flux1-dev.safetensors",
"weight_dtype": "default",
"sgld_options": [
"52",
0
]
},
"class_type": "SGLDUNETLoader",
"_meta": {
"title": "SGLDiffusion UNET Loader"
}
},
"52": {
"inputs": {
"model_type": "auto-detect",
"enable_torch_compile": false,
"num_gpus": 2,
"tp_size": -1,
"sp_degree": -1,
"ulysses_degree": -1,
"ring_degree": -1,
"dp_size": 1,
"dp_degree": 1,
"enable_cfg_parallel": false,
"attention_backend": "",
"cache_strategy": "none"
},
"class_type": "SGLDOptions",
"_meta": {
"title": "SGLDiffusion Options"
}
}
}
@@ -0,0 +1,165 @@
{
"3": {
"inputs": {
"seed": 808633539418610,
"steps": 4,
"cfg": 1,
"sampler_name": "euler",
"scheduler": "simple",
"denoise": 1,
"model": [
"66",
0
],
"positive": [
"6",
0
],
"negative": [
"7",
0
],
"latent_image": [
"58",
0
]
},
"class_type": "KSampler",
"_meta": {
"title": "KSampler"
}
},
"6": {
"inputs": {
"text": "\"A vibrant, warm neon-lit street scene in Hong Kong at the afternoon, with a mix of colorful Chinese and English signs glowing brightly. The atmosphere is lively, cinematic, and rain-washed with reflections on the pavement. The colors are vivid, full of pink, blue, red, and green hues. Crowded buildings with overlapping neon signs. 1980s Hong Kong style. Signs include:\n\"龍鳳冰室\" \"金華燒臘\" \"HAPPY HAIR\" \"鴻運茶餐廳\" \"EASY BAR\" \"永發魚蛋粉\" \"添記粥麵\" \"SUNSHINE MOTEL\" \"美都餐室\" \"富記糖水\" \"太平館\" \"雅芳髮型屋\" \"STAR KTV\" \"銀河娛樂城\" \"百樂門舞廳\" \"BUBBLE CAFE\" \"萬豪麻雀館\" \"CITY LIGHTS BAR\" \"瑞祥香燭莊\" \"文記文具\" \"GOLDEN JADE HOTEL\" \"LOVELY BEAUTY\" \"合興百貨\" \"興旺電器\" And the background is warm yellow street and with all stores' lights on.",
"speak_and_recognation": {
"__value__": [
false,
true
]
},
"clip": [
"38",
0
]
},
"class_type": "CLIPTextEncode",
"_meta": {
"title": "CLIP Text Encode (Positive Prompt)"
}
},
"7": {
"inputs": {
"text": "",
"speak_and_recognation": {
"__value__": [
false,
true
]
},
"clip": [
"38",
0
]
},
"class_type": "CLIPTextEncode",
"_meta": {
"title": "CLIP Text Encode (Negative Prompt)"
}
},
"8": {
"inputs": {
"samples": [
"3",
0
],
"vae": [
"39",
0
]
},
"class_type": "VAEDecode",
"_meta": {
"title": "VAE Decode"
}
},
"38": {
"inputs": {
"clip_name": "qwen_2.5_vl_7b_fp8_scaled.safetensors",
"type": "qwen_image",
"device": "default"
},
"class_type": "CLIPLoader",
"_meta": {
"title": "Load CLIP"
}
},
"39": {
"inputs": {
"vae_name": "qwen_image_vae.safetensors"
},
"class_type": "VAELoader",
"_meta": {
"title": "Load VAE"
}
},
"58": {
"inputs": {
"width": 1328,
"height": 1328,
"batch_size": 1
},
"class_type": "EmptySD3LatentImage",
"_meta": {
"title": "EmptySD3LatentImage"
}
},
"60": {
"inputs": {
"filename_prefix": "ComfyUI"
},
"class_type": "SaveImage",
"_meta": {
"title": "Save Image"
}
},
"66": {
"inputs": {
"shift": 3.1000000000000005,
"model": [
"78",
0
]
},
"class_type": "ModelSamplingAuraFlow",
"_meta": {
"title": "ModelSamplingAuraFlow"
}
},
"77": {
"inputs": {
"unet_name": "qwen_image_2512_bf16.safetensors",
"weight_dtype": "default"
},
"class_type": "SGLDUNETLoader",
"_meta": {
"title": "SGLDiffusion UNET Loader"
}
},
"78": {
"inputs": {
"lora_name": "Qwen-Image-2512-Lightning-4steps-V1.0-bf16.safetensors",
"strength_model": 1,
"nickname": "",
"target": "all",
"model": [
"77",
0
]
},
"class_type": "SGLDLoraLoader",
"_meta": {
"title": "SGLDiffusion LoRA Loader"
}
}
}
@@ -0,0 +1,97 @@
{
"1": {
"inputs": {
"base_url": "http://localhost:3000/v1",
"api_key": "sk-proj-1234567890"
},
"class_type": "SGLDiffusionServerModel",
"_meta": {
"title": "SGLDiffusion Server Model"
}
},
"3": {
"inputs": {
"prompt": "The girl turn the body and spin around in place.",
"main": "none",
"lighting": "none",
"speak_and_recognation": {
"__value__": [
false,
true
]
}
},
"class_type": "easy prompt",
"_meta": {
"title": "Prompt"
}
},
"4": {
"inputs": {
"text": "",
"anything": [
"1",
1
]
},
"class_type": "easy showAnything",
"_meta": {
"title": "Show Any"
}
},
"15": {
"inputs": {
"positive_prompt": [
"3",
0
],
"negative_prompt": "",
"seed": 2435791308,
"steps": 50,
"cfg": 4,
"width": 704,
"height": 1280,
"num_frames": 16,
"fps": 16,
"seconds": 1,
"enable_teacache": false,
"sgld_client": [
"1",
0
],
"image": [
"17",
0
]
},
"class_type": "SGLDiffusionGenerateVideo",
"_meta": {
"title": "SGLDiffusion Generate Video"
}
},
"16": {
"inputs": {
"filename_prefix": "video/ComfyUI",
"format": "auto",
"codec": "auto",
"video-preview": "",
"video": [
"15",
0
]
},
"class_type": "SaveVideo",
"_meta": {
"title": "save video"
}
},
"17": {
"inputs": {
"image": "tmpe_w0bd_0.jpg"
},
"class_type": "LoadImage",
"_meta": {
"title": "load image"
}
}
}
@@ -0,0 +1,109 @@
{
"1": {
"inputs": {
"base_url": "http://localhost:3000/v1",
"api_key": "sk-proj-1234567890"
},
"class_type": "SGLDiffusionServerModel",
"_meta": {
"title": "SGLDiffusion Server Model"
}
},
"3": {
"inputs": {
"prompt": "a bicycle, illustration in the style of SMPL, thick black lines on a white background",
"main": "none",
"lighting": "none",
"speak_and_recognation": {
"__value__": [
false,
true
]
}
},
"class_type": "easy prompt",
"_meta": {
"title": "Prompt"
}
},
"4": {
"inputs": {
"text": "",
"anything": [
"1",
1
]
},
"class_type": "easy showAnything",
"_meta": {
"title": "Show Any"
}
},
"5": {
"inputs": {
"filename_prefix": "ComfyUI",
"images": [
"6",
0
]
},
"class_type": "SaveImage",
"_meta": {
"title": "save image"
}
},
"6": {
"inputs": {
"positive_prompt": [
"3",
0
],
"negative_prompt": "",
"seed": 4215918563,
"steps": 50,
"cfg": 4,
"width": 512,
"height": 512,
"enable_teacache": false,
"sgld_client": [
"11",
0
],
"image": [
"14",
0
]
},
"class_type": "SGLDiffusionGenerateImage",
"_meta": {
"title": "SGLDiffusion Generate Image"
}
},
"11": {
"inputs": {
"lora_name": "dvyio/flux-lora-simple-illustration",
"lora_nickname": "",
"target": "all",
"sgld_client": [
"1",
0
]
},
"class_type": "SGLDiffusionSetLora",
"_meta": {
"title": "SGLDiffusion Set LoRA"
}
},
"14": {
"inputs": {
"width": 512,
"height": 512,
"batch_size": 1,
"color": 0
},
"class_type": "EmptyImage",
"_meta": {
"title": "empty image"
}
}
}
@@ -0,0 +1,140 @@
{
"3": {
"inputs": {
"seed": 3338398,
"steps": 9,
"cfg": 1,
"sampler_name": "euler",
"scheduler": "simple",
"denoise": 1,
"model": [
"28",
0
],
"positive": [
"6",
0
],
"negative": [
"7",
0
],
"latent_image": [
"13",
0
]
},
"class_type": "KSampler",
"_meta": {
"title": "KSampler"
}
},
"6": {
"inputs": {
"text": "cute anime style girl with massive fluffy fennec ears and a big fluffy tail blonde messy long hair blue eyes wearing a maid outfit with a long black gold leaf pattern dress and a white apron, it is a postcard held by a hand in front of a beautiful realistic city at sunset and there is cursive writing that says \"ZImage, Now in ComfyUI\"",
"speak_and_recognation": {
"__value__": [
false,
true
]
},
"clip": [
"18",
0
]
},
"class_type": "CLIPTextEncode",
"_meta": {
"title": "CLIP Text Encode (Positive Prompt)"
}
},
"7": {
"inputs": {
"text": "blurry ugly bad",
"speak_and_recognation": {
"__value__": [
false,
true
]
},
"clip": [
"18",
0
]
},
"class_type": "CLIPTextEncode",
"_meta": {
"title": "CLIP Text Encode (Negative Prompt)"
}
},
"8": {
"inputs": {
"samples": [
"3",
0
],
"vae": [
"17",
0
]
},
"class_type": "VAEDecode",
"_meta": {
"title": "VAE Decode"
}
},
"9": {
"inputs": {
"filename_prefix": "ComfyUI",
"images": [
"8",
0
]
},
"class_type": "SaveImage",
"_meta": {
"title": "Save Image"
}
},
"13": {
"inputs": {
"width": 1024,
"height": 1024,
"batch_size": 1
},
"class_type": "EmptySD3LatentImage",
"_meta": {
"title": "EmptySD3LatentImage"
}
},
"17": {
"inputs": {
"vae_name": "ae.safetensors"
},
"class_type": "VAELoader",
"_meta": {
"title": "VAE Loader"
}
},
"18": {
"inputs": {
"clip_name": "qwen_3_4b.safetensors",
"type": "lumina2",
"device": "default"
},
"class_type": "CLIPLoader",
"_meta": {
"title": "CLIP Loader"
}
},
"28": {
"inputs": {
"unet_name": "z_image_turbo_bf16.safetensors",
"weight_dtype": "default"
},
"class_type": "SGLDUNETLoader",
"_meta": {
"title": "SGLDiffusion UNET Loader"
}
}
}
@@ -0,0 +1,24 @@
# SGLang Diffusion Realtime WebUI
Standalone browser demo for `/v1/realtime_video/generate`.
Open `index.html` directly in a browser, point it at an SGLang Diffusion server,
and generate. The app sends msgpack init / event messages and renders lossless
raw RGB frame batches on a canvas.
The first version is intentionally static: no npm install, no build step, and no
server-side dependencies. Presets are UI-side templates for prompt, LingBot
example images, album artwork references, and session parameters. The default
preset preloads a reference image so the demo can be tested without a file
upload.
By default, `Continuous session` is enabled for long-running camera control.
Keyboard and pointer controls send state transitions instead of scripted preset
actions. The telemetry `Chunk wait` measures request-to-chunk arrival time, not
client-side RGB decode time. Continuous playback adapts to the measured chunk
production rate so the canvas does not play a chunk at target FPS and then sit
on the last frame while waiting for the next chunk.
The interface shape follows camera-control-first video playgrounds such as
Reactor LingBot: reference image, scene prompt, enhancement, clip controls,
move/look camera controls, recordings history, and model telemetry.
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,172 @@
const RAW_RGB_CONTENT_TYPE = "application/x-raw-rgb";
const RAW_RGB_DELTA_GZIP_CONTENT_TYPE = "application/x-raw-rgb-delta-gzip";
const RAW_RGBA_DELTA_GZIP_CONTENT_TYPE = "application/x-raw-rgba-delta-gzip";
const WEBP_FRAME_CONTENT_TYPE = "image/webp";
const JPEG_FRAME_CONTENT_TYPE = "image/jpeg";
let lastFrame = null;
function reset() {
lastFrame = null;
}
async function gunzipBytes(payload) {
if (typeof DecompressionStream === "undefined") {
throw new Error("This browser does not support gzip stream decoding");
}
const stream = new Blob([payload]).stream().pipeThrough(new DecompressionStream("gzip"));
return new Uint8Array(await new Response(stream).arrayBuffer());
}
async function restoreDeltaGzipFrames(header, payload) {
const frameBytes = Number(header.bytes_per_frame);
const count = Number(header.num_frames);
const expectedSize = frameBytes * count;
const restored = await gunzipBytes(payload);
if (restored.length !== expectedSize) {
throw new Error(`delta payload size mismatch: expected ${expectedSize}, got ${restored.length}`);
}
let previous = header.delta_reference === "previous-frame" ? lastFrame : null;
if (header.delta_reference === "previous-frame") {
if (!previous) throw new Error("Missing previous frame for delta payload");
if (previous.byteLength !== frameBytes) {
throw new Error("Previous frame size does not match current delta payload");
}
}
for (let f = 0; f < count; f++) {
const offset = f * frameBytes;
if (previous) {
for (let i = 0; i < frameBytes; i++) restored[offset + i] ^= previous[i];
}
previous = restored.slice(offset, offset + frameBytes);
}
lastFrame = previous;
return restored;
}
function rawFramesToRgbaBuffers(header, payload) {
const width = Number(header.width);
const height = Number(header.height);
const channels = Number(header.channels);
const count = Number(header.num_frames);
const frameBytes = Number(header.bytes_per_frame);
const pixels = width * height;
const buffers = [];
for (let f = 0; f < count; f++) {
const offset = f * frameBytes;
if (channels === 4) {
buffers.push(payload.buffer.slice(
payload.byteOffset + offset,
payload.byteOffset + offset + frameBytes,
));
continue;
}
const rgba = new Uint8ClampedArray(pixels * 4);
let src = offset;
let dst = 0;
for (let p = 0; p < pixels; p++) {
rgba[dst++] = payload[src++];
rgba[dst++] = payload[src++];
rgba[dst++] = payload[src++];
src += channels - 3;
rgba[dst++] = 255;
}
buffers.push(rgba.buffer);
}
return buffers;
}
function splitEncodedPayload(header, payload) {
const bytes = payload instanceof Uint8Array ? payload : new Uint8Array(payload);
const lengths = Array.isArray(header.payload_lengths) && header.payload_lengths.length
? header.payload_lengths.map(Number)
: [bytes.byteLength];
const payloads = [];
let offset = 0;
for (const length of lengths) {
payloads.push(bytes.buffer.slice(
bytes.byteOffset + offset,
bytes.byteOffset + offset + length,
));
offset += length;
}
return payloads;
}
async function encodedFramesToImageBitmaps(header, payload) {
if (typeof createImageBitmap === "undefined") {
throw new Error("This browser does not support worker image decoding");
}
const frames = await Promise.all(splitEncodedPayload(header, payload).map((framePayload) => (
createImageBitmap(new Blob([framePayload], { type: header.content_type }))
)));
return {
width: frames[0]?.width || 0,
height: frames[0]?.height || 0,
frame_type: "bitmap",
frames,
};
}
async function decode(header, payload) {
let rawPayload;
if (
header.content_type === WEBP_FRAME_CONTENT_TYPE ||
header.content_type === JPEG_FRAME_CONTENT_TYPE
) {
const decoded = await encodedFramesToImageBitmaps(header, payload);
return {
id: header.__decode_id,
width: decoded.width,
height: decoded.height,
chunk: Number(header.chunk_index),
frame_type: decoded.frame_type,
frames: decoded.frames,
};
} else if (header.content_type === RAW_RGB_CONTENT_TYPE) {
rawPayload = new Uint8Array(payload);
const frameBytes = Number(header.bytes_per_frame);
const count = Number(header.num_frames);
lastFrame = count > 0
? rawPayload.slice((count - 1) * frameBytes, count * frameBytes)
: null;
} else if (
header.content_type === RAW_RGB_DELTA_GZIP_CONTENT_TYPE ||
header.content_type === RAW_RGBA_DELTA_GZIP_CONTENT_TYPE
) {
rawPayload = await restoreDeltaGzipFrames(header, payload);
} else {
throw new Error(`Unsupported content type ${header.content_type}`);
}
return {
id: header.__decode_id,
width: Number(header.width),
height: Number(header.height),
chunk: Number(header.chunk_index),
frames: rawFramesToRgbaBuffers(header, rawPayload),
};
}
self.onmessage = async (event) => {
const message = event.data;
try {
if (message.type === "reset") {
reset();
return;
}
const result = await decode(message.header, message.payload);
self.postMessage({ type: "decoded", ...result }, result.frames);
} catch (error) {
self.postMessage({
type: "error",
id: message.header?.__decode_id,
message: error.message || "decode failed",
});
}
};
@@ -0,0 +1,168 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>sglang-diffusion Realtime Studio</title>
<link rel="stylesheet" href="./styles.css?v=realtime-record-v49" />
</head>
<body>
<main class="shell">
<section class="panel controls" aria-label="Session controls">
<div class="brand">
<span>sglang-diffusion</span>
<strong>Realtime Studio</strong>
</div>
<label>Server<input id="serverUrl" value="ws://127.0.0.1:30000/v1/realtime_video/generate" /></label>
<label>Model<input id="model" value="" placeholder="auto from /v1/models" /></label>
<div class="section-title">Reference</div>
<label class="reference-upload">
<input id="firstFrame" type="file" accept="image/*" />
<canvas id="referencePreview" width="320" height="180"></canvas>
<span id="referenceName">Preset reference</span>
</label>
<div class="section-title">Generate the scene</div>
<label>Prompt<textarea id="prompt" rows="4">A cinematic handheld shot of a quiet city street at dusk, soft reflections, natural motion.</textarea></label>
<button id="enhanceBtn" class="wide">Enhance</button>
<div class="split">
<label>Size<input id="size" value="832x480" /></label>
<label>FPS<input id="fps" type="number" value="25" min="1" max="60" /></label>
</div>
<div class="split">
<label>Frames<input id="numFrames" type="number" value="9" min="5" step="4" /></label>
<label>Seed<input id="seed" type="number" value="42" /></label>
</div>
<div class="split">
<label>Steps<input id="steps" type="number" value="4" min="1" /></label>
<label>Guidance<input id="guidance" type="number" value="1" step="0.1" /></label>
</div>
<div class="split">
<label>Sink<input id="sinkSize" type="number" value="9" min="0" /></label>
<label>Window<input id="windowFrames" type="number" value="18" min="1" /></label>
</div>
<div class="split">
<label>Transport
<select id="transportFormat">
<option value="webp" selected>WebP preview</option>
<option value="jpeg">JPEG preview</option>
<option value="">Lossless delta</option>
<option value="raw">Raw RGB</option>
</select>
</label>
<label>Quality<input id="transportQuality" type="number" value="95" min="1" max="100" /></label>
</div>
<div class="split output-options">
<label class="toggle-row"><input id="superResolution" type="checkbox" />Super resolution</label>
<label>Scale
<select id="upscalingScale">
<option value="2" selected>2x</option>
<option value="4">4x</option>
</select>
</label>
</div>
<label>SR model
<select id="upscalingModel">
<option value="">Quality x2</option>
<option
value="https://github.com/xinntao/Real-ESRGAN/releases/download/v0.2.5.0/realesr-general-x4v3.pth"
selected
>
Fast general
</option>
<option value="/scratch/realesr-animevideov3.pth">Fast anime</option>
</select>
</label>
<label class="toggle-row"><input id="frameInterpolation" type="checkbox" />Smooth 2x frames</label>
<label class="toggle-row"><input id="continuous" type="checkbox" checked />Continuous session</label>
<div class="actions">
<button id="connectBtn" class="primary">Generate</button>
<button id="stopBtn">Close session</button>
</div>
<button id="sendPromptBtn" class="wide">Send prompt update</button>
</section>
<section class="workspace" aria-label="Realtime workspace">
<section class="stage" aria-label="Realtime preview">
<div class="topbar">
<span id="statusDot" class="dot"></span>
<span id="statusText">Idle</span>
<span id="chunkText">chunk -</span>
<button id="recordBtn" class="record-button" type="button" aria-pressed="false" title="Record preview">
<span class="record-button-icon" aria-hidden="true"></span>
<span id="recordLabel">Record</span>
<span id="recordDuration" class="record-button-duration">00:00</span>
</button>
<span class="topbar-spacer"></span>
<label class="preview-scale-control">Preview
<input id="previewScale" type="range" min="80" max="170" value="120" />
<b id="previewScaleText">120%</b>
</label>
<span class="stage-stat">output <b id="outputSizeText">832x480</b></span>
<span class="stage-stat">render <b id="renderFps">0</b> fps</span>
<span class="stage-stat">source <b id="theoreticalFpsText">-</b></span>
<span class="stage-stat">buffer <b id="stageLatencyText">-</b></span>
</div>
<div class="preview-frame">
<canvas id="viewport" width="1280" height="720"></canvas>
<div id="previewOverlay" class="preview-overlay" aria-hidden="true">
<span class="preview-loader"></span>
</div>
</div>
<div class="stage-controls" aria-label="Camera controls">
<div class="control-cluster" aria-label="Move camera">
<span class="control-title">Move</span>
<div class="camera-pad move-pad">
<span></span>
<button data-action="w" data-key="W">Forward</button>
<span></span>
<button data-action="a" data-key="A">Left</button>
<button data-action="s" data-key="S">Back</button>
<button data-action="d" data-key="D">Right</button>
</div>
</div>
<div class="control-cluster" aria-label="Look around">
<span class="control-title">Look</span>
<div class="camera-pad look-pad">
<span></span>
<button data-action="i" data-key="↑">Pitch +</button>
<span></span>
<button data-action="j" data-key="←">Yaw -</button>
<button data-action="k" data-key="↓">Pitch -</button>
<button data-action="l" data-key="→">Yaw +</button>
</div>
</div>
</div>
<div class="timeline">
<span id="queueText">queue 0</span>
<span id="frameText">frames 0</span>
<span id="byteText">0 MB</span>
</div>
<div class="telemetry stage-telemetry">
<span>Payload<b id="payloadMode">webp</b></span>
<span>Server send<b id="serverSendText">-</b></span>
<span>Chunk bytes<b id="chunkPayloadText">-</b></span>
<span>Chunk wait<b id="latencyText">-</b></span>
<span>Decode<b id="decodeText">-</b></span>
<span>Display lag<b id="displayLagText">-</b></span>
</div>
</section>
<section class="panel presets" aria-label="Presets and camera">
<div class="section-title">LingBot</div>
<div class="spec-grid">
<span><b>25 fps</b> target</span>
<span><b>chunked</b> stream</span>
<span><b>480p/720p</b></span>
<span><b>Cam + Act</b></span>
</div>
<div class="section-title">Presets</div>
<div id="presetList" class="preset-list"></div>
<div class="section-title">History</div>
<div id="historyList" class="history-list"></div>
</section>
</section>
</main>
<script src="./playback_controller.js?v=realtime-playback-v13"></script>
<script src="./app.js?v=realtime-record-v75"></script>
</body>
</html>
@@ -0,0 +1,517 @@
(function attachRealtimePlaybackController(global) {
const DEFAULT_CONFIG = {
targetFps: 25,
minSourceFps: 1,
serverFpsAlphaUp: 0.28,
serverFpsAlphaDown: 0.2,
deliveryFpsAlphaUp: 0.08,
deliveryFpsAlphaDown: 0.55,
targetLeadChunkRatio: 1.5,
minTargetLeadMs: 1500,
maxTargetLeadMs: 2600,
maxLeadExtraChunkRatio: 8.0,
startLeadChunkRatio: 1.85,
minStartLeadMs: 1700,
resumeLeadChunkRatio: 2.5,
minResumeLeadMs: 1000,
maxResumeLeadMs: 1800,
rebufferLeadBoostMs: 250,
rebufferLeadBoostDecayMsPerSecond: 120,
deliveryLeadBoostDecayMsPerSecond: 80,
maxDeliveryLeadBoostMs: 2000,
deliveryStallExpectedMultiplier: 1.25,
receiveStallPlaybackRateMin: 0.65,
receiveStallPlaybackRateSlewPerSecond: 0.5,
lowWaterRatio: 0.4,
playbackRateGain: 0.14,
playbackRateMin: 0.92,
playbackRateMax: 1.08,
emergencyPlaybackRateMin: 0.9,
emergencyPlaybackRateMax: 1.12,
playbackRateSlewPerSecond: 0.08,
eventCutoverMaxMs: 420,
eventCutoverMaxFrames: 10,
settleEventCutoverMaxMs: 720,
settleEventCutoverMaxFrames: 18,
startupWarmupMinMs: 1500,
startupWarmupExpectedMultiplier: 3,
};
function clamp(value, min, max) {
return Math.min(max, Math.max(min, value));
}
function finitePositive(value) {
return Number.isFinite(value) && value > 0;
}
class RealtimePlaybackController {
constructor(config = {}) {
this.config = { ...DEFAULT_CONFIG, ...config };
this.reset({ targetFps: this.config.targetFps });
}
reset({ targetFps } = {}) {
this.targetFps = Math.max(1, Number(targetFps || this.config.targetFps));
this.sourceFps = this.targetFps;
this.serverFps = this.targetFps;
this.deliveryFps = this.targetFps;
this.hasServerSample = false;
this.hasDeliverySample = false;
this.latestChunkDurationMs = 1000 / this.targetFps;
this.latestChunkFrames = 1;
this.playbackRate = 1;
this.renderFps = this.targetFps;
this.queue = [];
this.lastDrawAt = 0;
this.lastRateUpdateAt = 0;
this.renderedFrames = 0;
this.droppedFrames = 0;
this.buffering = true;
this.pendingEventId = 0;
this.pendingEventSentAt = 0;
this.pendingEventCutoverMode = "motion";
this.lastDropReason = "";
this.lastDropAt = 0;
this.lastDropCount = 0;
this.rebufferLeadBoostMs = 0;
this.deliveryLeadBoostMs = 0;
this.chunkReceives = new Map();
this.serverStatChunks = new Set();
this.lastFinalReceiveAt = 0;
this.receiveStalled = false;
}
setTargetFps(targetFps) {
const nextTargetFps = Math.max(1, Number(targetFps || this.config.targetFps));
this.targetFps = nextTargetFps;
if (!this.hasServerSample && !this.hasDeliverySample) {
this.serverFps = nextTargetFps;
this.deliveryFps = nextTargetFps;
this.sourceFps = nextTargetFps;
this.renderFps = nextTargetFps;
} else {
this.serverFps = clamp(this.serverFps, this.config.minSourceFps, nextTargetFps);
this.deliveryFps = clamp(this.deliveryFps, this.config.minSourceFps, nextTargetFps);
this.sourceFps = clamp(this.sourceFps, this.config.minSourceFps, nextTargetFps);
this.renderFps = this.sourceFps * this.playbackRate;
}
this.latestChunkDurationMs = Math.max(this.latestChunkDurationMs, 1000 / this.targetFps);
}
clear() {
const frames = this.queue.splice(0);
this.lastDrawAt = 0;
this.buffering = true;
return frames;
}
noteInputEvent(eventId, now, { cutoverMode = "motion" } = {}) {
this.pendingEventId = Number(eventId || 0);
this.pendingEventSentAt = Number(now || 0);
this.pendingEventCutoverMode = cutoverMode;
}
observeServerStats(stats, now) {
const chunkIndex = Number(stats.chunk_index || 0);
const numFrames = Number(stats.num_frames || 0);
const chunkTotalMs = Number(stats.chunk_total_ms || 0);
if (numFrames > 0 && chunkTotalMs > 0) {
this.serverStatChunks.add(chunkIndex);
if (this.serverStatChunks.size > 128) {
this.serverStatChunks.delete(this.serverStatChunks.values().next().value);
}
const expectedMs = numFrames / Math.max(1, this.targetFps) * 1000;
const isStartupWarmup =
chunkIndex === 0 &&
chunkTotalMs > Math.max(
this.config.startupWarmupMinMs,
expectedMs * this.config.startupWarmupExpectedMultiplier,
);
if (isStartupWarmup) return this.snapshot();
this.#observeFpsSample("server", {
fps: numFrames / (chunkTotalMs / 1000),
frameCount: numFrames,
durationMs: chunkTotalMs,
now,
});
}
return this.snapshot();
}
enqueueDecodedFrames(header, frames, now) {
const chunkIndex = Number(header.chunk_index || 0);
const eventId = Number(header.event_id || 0);
const receivedAt = Number(header.__received_at || now);
const preparedFrames = frames.map((frame) => ({
...frame,
chunk: Number(frame.chunk ?? chunkIndex),
chunkIndex,
eventId,
}));
const droppedFrames = [];
let cutover = null;
if (this.pendingEventId && eventId >= this.pendingEventId) {
const oldEventFrameCount = this.#oldEventFrameCount(eventId);
const graceFrames = this.#eventGraceFrames();
const dropCount = Math.max(0, oldEventFrameCount - graceFrames);
if (dropCount > 0) {
droppedFrames.push(...this.queue.splice(graceFrames, dropCount));
this.#recordDrop(dropCount, "event cutover", now);
}
cutover = {
eventId,
latencyMs: this.pendingEventSentAt ? now - this.pendingEventSentAt : 0,
};
this.pendingEventId = 0;
this.pendingEventSentAt = 0;
this.pendingEventCutoverMode = "motion";
}
this.queue.push(...preparedFrames);
this.#observeChunkArrival(header, preparedFrames.length, receivedAt, now);
droppedFrames.push(...this.#trimBacklog(now));
return { droppedFrames, cutover, snapshot: this.snapshot() };
}
render(now, { hasPendingInput = true } = {}) {
this.#decayRebufferBoost(now);
this.#updateReceiveStallGuard(now);
const droppedFrames = this.#trimBacklog(now);
if (!this.queue.length) {
if (this.renderedFrames && hasPendingInput && !this.buffering) {
this.buffering = true;
this.rebufferLeadBoostMs = Math.max(
this.rebufferLeadBoostMs,
this.config.rebufferLeadBoostMs,
);
}
return { action: "hold", droppedFrames, snapshot: this.snapshot() };
}
const bufferMs = this.bufferDurationMs;
if (
hasPendingInput &&
this.receiveStalled &&
this.renderedFrames &&
bufferMs < this.targetLeadMs
) {
this.buffering = true;
this.lastDrawAt = 0;
return { action: "hold", droppedFrames, snapshot: this.snapshot() };
}
if (
hasPendingInput &&
this.buffering &&
bufferMs < (this.renderedFrames ? this.#resumeLeadMs() : this.#startLeadMs())
) {
this.buffering = true;
this.lastDrawAt = 0;
return { action: "hold", droppedFrames, snapshot: this.snapshot() };
}
if (this.buffering) {
this.buffering = false;
this.lastDrawAt = 0;
}
this.#updatePlaybackRate(now);
const targetMs = 1000 / Math.max(1, this.renderFps);
const elapsedMs = this.lastDrawAt ? now - this.lastDrawAt : targetMs;
if (elapsedMs < targetMs) {
return { action: "wait", droppedFrames, snapshot: this.snapshot() };
}
const frame = this.queue.shift();
this.renderedFrames += 1;
this.lastDrawAt = !this.lastDrawAt || elapsedMs > targetMs * 4
? now
: now - (elapsedMs % targetMs);
return { action: "draw", frame, droppedFrames, snapshot: this.snapshot() };
}
get queuedFrames() {
return this.queue.length;
}
get bufferDurationMs() {
return this.queue.length / Math.max(1, this.sourceFps) * 1000;
}
get targetLeadMs() {
const base = clamp(
this.latestChunkDurationMs * this.config.targetLeadChunkRatio,
this.config.minTargetLeadMs,
this.config.maxTargetLeadMs,
);
return clamp(
base + this.rebufferLeadBoostMs + this.deliveryLeadBoostMs,
this.config.minTargetLeadMs,
this.config.maxTargetLeadMs +
this.config.rebufferLeadBoostMs +
this.config.maxDeliveryLeadBoostMs,
);
}
get maxLeadMs() {
return this.targetLeadMs + this.latestChunkDurationMs * this.config.maxLeadExtraChunkRatio;
}
snapshot() {
return {
queueFrames: this.queue.length,
bufferMs: this.bufferDurationMs,
targetLeadMs: this.targetLeadMs,
maxLeadMs: this.maxLeadMs,
sourceFps: this.sourceFps,
serverFps: this.serverFps,
deliveryFps: this.deliveryFps,
targetFps: this.targetFps,
renderFps: this.renderFps,
playbackRate: this.playbackRate,
droppedFrames: this.droppedFrames,
lastDropAt: this.lastDropAt,
lastDropCount: this.lastDropCount,
buffering: this.buffering,
lastDropReason: this.lastDropReason,
};
}
#observeFpsSample(kind, { fps, frameCount, durationMs, now }) {
if (!finitePositive(fps)) return;
const cappedFps = clamp(fps, this.config.minSourceFps, this.targetFps);
const isDelivery = kind === "delivery";
const currentFps = isDelivery ? this.deliveryFps : this.serverFps;
const hasSample = isDelivery ? this.hasDeliverySample : this.hasServerSample;
let nextFps;
if (!hasSample) {
nextFps = cappedFps;
} else {
const alpha = cappedFps > currentFps
? (isDelivery ? this.config.deliveryFpsAlphaUp : this.config.serverFpsAlphaUp)
: (isDelivery ? this.config.deliveryFpsAlphaDown : this.config.serverFpsAlphaDown);
nextFps = currentFps * (1 - alpha) + cappedFps * alpha;
}
if (isDelivery) {
this.deliveryFps = nextFps;
this.hasDeliverySample = true;
this.#observeDeliveryJitter(frameCount, durationMs);
} else {
this.serverFps = nextFps;
this.hasServerSample = true;
}
const effectiveFps = this.hasServerSample
? this.serverFps
: (this.hasDeliverySample ? this.deliveryFps : this.targetFps);
this.sourceFps = clamp(effectiveFps, this.config.minSourceFps, this.targetFps);
if (!isDelivery || !this.hasServerSample) {
this.latestChunkFrames = Math.max(1, Number(frameCount || this.latestChunkFrames));
this.latestChunkDurationMs = clamp(
Number(durationMs || (this.latestChunkFrames / Math.max(1, this.sourceFps) * 1000)),
1000 / Math.max(1, this.targetFps),
2500,
);
}
this.#updatePlaybackRate(now);
}
#observeDeliveryJitter(frameCount, durationMs) {
if (!this.hasServerSample || !finitePositive(durationMs)) return;
const expectedMs = Number(frameCount || 0) / Math.max(1, this.serverFps) * 1000;
if (expectedMs <= 0) return;
if (durationMs <= expectedMs * this.config.deliveryStallExpectedMultiplier) return;
const boostMs = clamp(
durationMs - expectedMs,
0,
this.config.maxDeliveryLeadBoostMs,
);
this.deliveryLeadBoostMs = Math.max(this.deliveryLeadBoostMs, boostMs);
}
#updateReceiveStallGuard(now) {
this.receiveStalled = false;
if (!this.lastFinalReceiveAt || !this.hasServerSample) return;
const elapsedMs = now - this.lastFinalReceiveAt;
const expectedMs = Math.max(
this.latestChunkDurationMs,
this.latestChunkFrames / Math.max(1, this.serverFps) * 1000,
);
if (elapsedMs <= expectedMs * this.config.deliveryStallExpectedMultiplier) return;
this.receiveStalled = true;
this.deliveryLeadBoostMs = Math.max(
this.deliveryLeadBoostMs,
clamp(elapsedMs - expectedMs, 0, this.config.maxDeliveryLeadBoostMs),
);
}
#observeChunkArrival(header, frameCount, receivedAt, now) {
const chunkIndex = Number(header.chunk_index || 0);
const state = this.chunkReceives.get(chunkIndex) || {
firstAt: receivedAt,
frames: 0,
};
state.frames += Number(frameCount || 0);
state.lastAt = receivedAt;
this.chunkReceives.set(chunkIndex, state);
const frameBatchIndex = Number(header.frame_batch_index || 0);
const numFrameBatches = Number(header.num_frame_batches || 1);
const isFinalFrameBatch =
Boolean(header.is_final_frame_batch) ||
frameBatchIndex + 1 >= numFrameBatches;
if (!isFinalFrameBatch) return;
const durationMs = this.lastFinalReceiveAt
? receivedAt - this.lastFinalReceiveAt
: 0;
this.lastFinalReceiveAt = receivedAt;
if (state.frames > 0 && durationMs > 0) {
this.#observeFpsSample("delivery", {
fps: state.frames / (durationMs / 1000),
frameCount: state.frames,
durationMs,
now,
});
}
this.chunkReceives.delete(chunkIndex);
}
#updatePlaybackRate(now) {
const bufferMs = this.bufferDurationMs;
const targetLeadMs = Math.max(1, this.targetLeadMs);
const error = (bufferMs - targetLeadMs) / targetLeadMs;
const emergency =
bufferMs > this.maxLeadMs ||
bufferMs < targetLeadMs * this.config.lowWaterRatio ||
(this.receiveStalled && bufferMs < targetLeadMs);
const minRate = emergency
? (
this.receiveStalled
? this.config.receiveStallPlaybackRateMin
: this.config.emergencyPlaybackRateMin
)
: this.config.playbackRateMin;
const maxRate = this.receiveStalled && bufferMs < targetLeadMs
? 1
: emergency
? this.config.emergencyPlaybackRateMax
: this.config.playbackRateMax;
const desiredRate = clamp(
1 + error * this.config.playbackRateGain,
minRate,
maxRate,
);
if (!this.lastRateUpdateAt) {
this.playbackRate = desiredRate;
} else {
const dtSeconds = Math.max(0.001, (now - this.lastRateUpdateAt) / 1000);
const slewPerSecond = this.receiveStalled
? this.config.receiveStallPlaybackRateSlewPerSecond
: this.config.playbackRateSlewPerSecond;
const maxDelta = slewPerSecond * dtSeconds;
this.playbackRate = clamp(
desiredRate,
this.playbackRate - maxDelta,
this.playbackRate + maxDelta,
);
}
this.lastRateUpdateAt = now;
this.renderFps = clamp(
this.sourceFps * this.playbackRate,
this.config.minSourceFps,
this.targetFps * this.config.emergencyPlaybackRateMax,
);
}
#trimBacklog(now) {
const droppedFrames = [];
while (this.queue.length && this.bufferDurationMs > this.maxLeadMs) {
const firstChunk = this.queue[0].chunkIndex;
let dropCount = 0;
while (
dropCount < this.queue.length &&
this.queue[dropCount].chunkIndex === firstChunk
) {
dropCount += 1;
}
if (!dropCount || dropCount >= this.queue.length) break;
droppedFrames.push(...this.queue.splice(0, dropCount));
this.#recordDrop(dropCount, "backlog", now);
}
return droppedFrames;
}
#oldEventFrameCount(nextEventId) {
let count = 0;
while (count < this.queue.length && this.queue[count].eventId < nextEventId) {
count += 1;
}
return count;
}
#eventGraceFrames() {
const byTime = Math.max(
1,
Math.round(this.sourceFps * this.#eventCutoverMaxMs() / 1000),
);
const byChunkRatio = this.pendingEventCutoverMode === "settle" ? 1.5 : 0.85;
const byChunk = Math.max(1, Math.round(this.latestChunkFrames * byChunkRatio));
return Math.min(this.#eventCutoverMaxFrames(), byTime, byChunk);
}
#eventCutoverMaxMs() {
return this.pendingEventCutoverMode === "settle"
? this.config.settleEventCutoverMaxMs
: this.config.eventCutoverMaxMs;
}
#eventCutoverMaxFrames() {
return this.pendingEventCutoverMode === "settle"
? this.config.settleEventCutoverMaxFrames
: this.config.eventCutoverMaxFrames;
}
#startLeadMs() {
return Math.max(
this.config.minStartLeadMs,
this.latestChunkDurationMs * this.config.startLeadChunkRatio,
this.targetLeadMs,
);
}
#resumeLeadMs() {
return clamp(
this.latestChunkDurationMs * this.config.resumeLeadChunkRatio,
this.config.minResumeLeadMs,
this.config.maxResumeLeadMs,
);
}
#decayRebufferBoost(now) {
if ((!this.rebufferLeadBoostMs && !this.deliveryLeadBoostMs) || !this.lastRateUpdateAt) return;
const dtSeconds = Math.max(0, (now - this.lastRateUpdateAt) / 1000);
this.rebufferLeadBoostMs = Math.max(
0,
this.rebufferLeadBoostMs - dtSeconds * this.config.rebufferLeadBoostDecayMsPerSecond,
);
this.deliveryLeadBoostMs = Math.max(
0,
this.deliveryLeadBoostMs - dtSeconds * this.config.deliveryLeadBoostDecayMsPerSecond,
);
}
#recordDrop(count, reason, now) {
this.droppedFrames += count;
this.lastDropAt = Number(now || 0);
this.lastDropCount = count;
this.lastDropReason = reason;
}
}
global.RealtimePlaybackController = RealtimePlaybackController;
if (typeof module !== "undefined" && module.exports) {
module.exports = { RealtimePlaybackController };
}
})(typeof globalThis !== "undefined" ? globalThis : window);
@@ -0,0 +1,116 @@
const assert = require("node:assert/strict");
const { RealtimePlaybackController } = require("./playback_controller.js");
function frames(count, chunk) {
return Array.from({ length: count }, (_, index) => ({
image: { close() {} },
chunk,
index,
}));
}
function enqueueChunk(controller, {
chunk,
eventId = 0,
frameCount = 12,
durationMs = 480,
now,
}) {
controller.observeServerStats({
chunk_index: chunk,
num_frames: frameCount,
chunk_total_ms: durationMs,
}, now);
return controller.enqueueDecodedFrames({
chunk_index: chunk,
event_id: eventId,
num_frames: frameCount,
__received_at: now,
is_final_frame_batch: true,
}, frames(frameCount, chunk), now);
}
function renderFor(controller, startMs, durationMs) {
let rendered = 0;
for (let now = startMs; now <= startMs + durationMs; now += 16.67) {
const decision = controller.render(now, { hasPendingInput: true });
if (decision.action === "draw") rendered += 1;
}
return rendered;
}
function stableSourceDoesNotDrop() {
const controller = new RealtimePlaybackController({ targetFps: 25 });
let now = 0;
for (let chunk = 0; chunk < 8; chunk += 1) {
now += 480;
enqueueChunk(controller, { chunk, now });
renderFor(controller, now, 480);
}
const snapshot = controller.snapshot();
assert.equal(snapshot.droppedFrames, 0);
assert.ok(snapshot.sourceFps > 24 && snapshot.sourceFps <= 25);
}
function slowServerCapsRenderFps() {
const controller = new RealtimePlaybackController({ targetFps: 25 });
let now = 0;
for (let chunk = 0; chunk < 8; chunk += 1) {
now += 1360;
enqueueChunk(controller, { chunk, durationMs: 1360, now });
renderFor(controller, now, 1360);
}
const snapshot = controller.snapshot();
assert.ok(snapshot.sourceFps > 8 && snapshot.sourceFps < 10);
assert.ok(snapshot.renderFps <= 10);
}
function backlogDropsContiguousOldFrames() {
const controller = new RealtimePlaybackController({ targetFps: 25 });
let now = 100;
for (let chunk = 0; chunk < 13; chunk += 1) {
enqueueChunk(controller, { chunk, now, durationMs: 480 });
now += 20;
}
const snapshot = controller.snapshot();
assert.ok(snapshot.droppedFrames > 0);
assert.equal(snapshot.lastDropReason, "backlog");
}
function eventCutoverKeepsShortGrace() {
const controller = new RealtimePlaybackController({ targetFps: 25 });
enqueueChunk(controller, { chunk: 1, frameCount: 24, durationMs: 960, now: 1000 });
controller.noteInputEvent(5, 1050);
const result = enqueueChunk(controller, {
chunk: 2,
eventId: 5,
frameCount: 12,
durationMs: 480,
now: 1150,
});
assert.ok(result.cutover);
assert.ok(result.droppedFrames.length >= 14);
assert.equal(controller.queue[0].chunk, 1);
assert.equal(controller.queue[0].index, 0);
}
function settleEventCutoverKeepsWiderGrace() {
const controller = new RealtimePlaybackController({ targetFps: 25 });
enqueueChunk(controller, { chunk: 1, frameCount: 24, durationMs: 960, now: 1000 });
controller.noteInputEvent(5, 1050, { cutoverMode: "settle" });
const result = enqueueChunk(controller, {
chunk: 2,
eventId: 5,
frameCount: 12,
durationMs: 480,
now: 1150,
});
assert.ok(result.cutover);
assert.ok(result.droppedFrames.length <= 12);
}
stableSourceDoesNotDrop();
slowServerCapsRenderFps();
backlogDropsContiguousOldFrames();
eventCutoverKeepsShortGrace();
settleEventCutoverKeepsWiderGrace();
@@ -0,0 +1,714 @@
:root {
--paper: #eef1ec;
--panel: #fbfaf5;
--ink: #171a16;
--muted: #687164;
--line: #cbd2c4;
--accent: #b9543c;
--green: #4d765f;
--blue: #3f607c;
--pressed: #8c9288;
--pressed-border: #aeb4aa;
--pressed-ring: rgba(238, 241, 236, 0.2);
--shadow: 0 18px 60px rgba(23, 26, 22, 0.12);
}
* { box-sizing: border-box; }
body {
margin: 0;
overflow-x: hidden;
min-height: 100vh;
background:
linear-gradient(90deg, rgba(23, 26, 22, 0.035) 1px, transparent 1px),
linear-gradient(180deg, rgba(23, 26, 22, 0.035) 1px, transparent 1px),
var(--paper);
background-size: 28px 28px;
color: var(--ink);
font-family: ui-sans-serif, "Avenir Next", "Helvetica Neue", sans-serif;
}
button, input, textarea, select { font: inherit; }
button:disabled { cursor: wait; opacity: 0.64; transform: none; }
.shell {
display: grid;
grid-template-columns: minmax(260px, 320px) minmax(0, 1fr);
gap: 18px;
width: 100%;
max-width: 100vw;
min-height: 100vh;
padding: 18px;
}
.panel {
background: color-mix(in oklch, var(--panel), white 20%);
border: 1px solid var(--line);
border-radius: 8px;
box-shadow: var(--shadow);
padding: 18px;
}
.brand {
display: flex;
align-items: baseline;
gap: 10px;
margin-bottom: 22px;
}
.brand span {
color: var(--panel);
background: var(--ink);
border-radius: 4px;
padding: 3px 7px;
font-size: 12px;
letter-spacing: 0;
}
.brand strong { font-size: 18px; font-weight: 650; }
label {
display: grid;
gap: 7px;
margin: 12px 0;
color: var(--muted);
font-size: 12px;
}
.label-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.help-tooltip {
position: relative;
display: inline-grid;
place-items: center;
width: 18px;
height: 18px;
border: 1px solid var(--line);
border-radius: 50%;
color: var(--muted);
background: #fffdf7;
cursor: help;
font-size: 11px;
line-height: 1;
}
.help-tooltip::after {
content: attr(aria-label);
position: absolute;
right: 0;
bottom: calc(100% + 8px);
z-index: 20;
width: 280px;
max-width: min(280px, calc(100vw - 48px));
padding: 9px 10px;
border-radius: 6px;
background: var(--ink);
box-shadow: 0 12px 36px rgba(23, 26, 22, 0.24);
color: var(--panel);
font-size: 11px;
font-weight: 400;
line-height: 1.4;
opacity: 0;
pointer-events: none;
transform: translateY(4px);
transition: opacity 120ms ease, transform 120ms ease;
}
.help-tooltip:hover::after,
.help-tooltip:focus-visible::after {
opacity: 1;
transform: translateY(0);
}
input, textarea, select {
width: 100%;
border: 1px solid var(--line);
border-radius: 6px;
background: #fffdf7;
color: var(--ink);
padding: 10px 11px;
outline: none;
}
textarea { resize: vertical; line-height: 1.45; }
input:focus, textarea:focus, select:focus { border-color: var(--accent); box-shadow: 0 0 0 3px rgba(185, 84, 60, 0.12); }
.split { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; }
.output-options {
align-items: end;
}
.output-options .toggle-row {
min-height: 40px;
margin: 12px 0;
}
.actions { display: grid; grid-template-columns: 1fr 0.7fr; gap: 10px; margin-top: 16px; }
.toggle-row {
display: flex;
align-items: center;
gap: 9px;
margin-top: 14px;
}
.toggle-row input {
width: 16px;
height: 16px;
accent-color: var(--ink);
}
button {
border: 1px solid var(--line);
border-radius: 6px;
color: var(--ink);
background: #fffdf7;
min-height: 38px;
padding: 0 12px;
cursor: pointer;
transition:
background-color 120ms ease,
border-color 120ms ease,
box-shadow 120ms ease,
color 120ms ease,
transform 120ms ease;
}
button:hover:not(:disabled) {
border-color: var(--ink);
background: color-mix(in oklch, #fffdf7, var(--green) 10%);
box-shadow: 0 8px 18px rgba(23, 26, 22, 0.08);
transform: translateY(-1px);
}
button:active:not(:disabled),
button.is-pressed:not(:disabled) {
border-color: var(--pressed-border);
background: var(--pressed);
color: #fffdf7;
box-shadow:
inset 0 0 0 1px rgba(255, 253, 247, 0.18),
inset 0 2px 7px rgba(23, 26, 22, 0.16);
transform: translateY(0);
}
button.is-key-active:not(:disabled) {
border-color: var(--pressed-border);
background: var(--pressed);
color: #fffdf7;
box-shadow:
inset 0 0 0 1px rgba(255, 253, 247, 0.22),
0 0 0 3px var(--pressed-ring),
0 10px 22px rgba(23, 26, 22, 0.18);
transform: none;
}
button:focus-visible {
outline: none;
box-shadow: 0 0 0 3px rgba(185, 84, 60, 0.18);
}
.primary { background: var(--ink); color: var(--panel); border-color: var(--ink); }
.primary:hover:not(:disabled) {
background: color-mix(in oklch, var(--ink), var(--green) 18%);
color: var(--panel);
}
.primary:active:not(:disabled),
.primary.is-pressed:not(:disabled) {
background: var(--pressed);
border-color: var(--pressed-border);
color: var(--panel);
}
.wide { width: 100%; margin-top: 10px; }
.workspace {
display: grid;
gap: 18px;
min-width: 0;
max-width: 100%;
}
.stage {
position: relative;
display: grid;
grid-template-rows: auto auto auto auto auto;
align-self: start;
justify-self: center;
min-width: 0;
width: 100%;
max-width: min(1500px, 100%);
overflow: hidden;
border: 1px solid #11140f;
border-radius: 8px;
background: #11140f;
box-shadow: var(--shadow);
}
.preview-frame {
position: relative;
display: grid;
place-items: center;
justify-self: center;
width: min(calc(1040px * var(--preview-scale, 1.2)), 100%);
overflow: hidden;
background: #11140f;
contain: paint;
isolation: isolate;
}
.preview-frame::before {
content: "";
position: absolute;
inset: 0;
z-index: 0;
pointer-events: none;
background: linear-gradient(
180deg,
rgba(238, 241, 236, 0.045),
transparent 34%,
rgba(0, 0, 0, 0.18)
);
}
.preview-frame::after {
content: none;
}
.stage[data-preview-state="waiting"] .preview-frame::after {
animation: none;
}
.topbar, .timeline {
display: flex;
align-items: center;
gap: 10px;
min-width: 0;
height: 44px;
padding: 0 14px;
color: #e8eadf;
background: rgba(17, 20, 15, 0.88);
font-size: 12px;
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
.topbar > * {
flex: 0 0 auto;
align-self: center;
}
.topbar-spacer { flex: 1; }
.record-button {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
flex: 0 0 118px;
width: 118px;
min-height: 28px;
height: 28px;
padding: 0 9px;
border-color: rgba(232, 234, 223, 0.22);
background: rgba(238, 241, 236, 0.08);
color: #e8eadf;
font-size: 11px;
font-variant-numeric: tabular-nums;
}
.record-button:hover:not(:disabled) {
border-color: rgba(232, 234, 223, 0.44);
background: rgba(238, 241, 236, 0.14);
box-shadow: none;
transform: none;
}
.record-button:active:not(:disabled),
.record-button.is-pressed:not(:disabled),
.record-button:focus-visible {
transform: none;
}
.record-button.is-recording {
border-color: color-mix(in oklch, var(--accent), white 18%);
background: var(--accent);
color: #fffdf7;
}
.record-button.is-saving {
cursor: wait;
opacity: 0.76;
}
#recordLabel {
flex: 0 0 36px;
text-align: left;
}
.record-button-icon {
flex: 0 0 9px;
width: 9px;
height: 9px;
min-width: 9px;
border-radius: 50%;
background: var(--accent);
box-shadow: 0 0 0 3px rgba(185, 84, 60, 0.16);
}
.record-button.is-recording .record-button-icon {
border-radius: 2px;
background: #fffdf7;
box-shadow: none;
}
.record-button-duration {
display: inline-block;
flex: 0 0 34px;
min-width: 34px;
text-align: right;
color: rgba(232, 234, 223, 0.7);
}
.record-button.is-recording .record-button-duration {
color: rgba(255, 253, 247, 0.86);
}
.preview-scale-control {
display: inline-flex;
align-items: center;
gap: 8px;
flex: 0 0 170px;
min-width: 170px;
margin: 0;
color: rgba(232, 234, 223, 0.72);
font-size: 11px;
line-height: 1;
}
.preview-scale-control input {
width: 92px;
min-width: 72px;
padding: 0;
border: 0;
background: transparent;
accent-color: #eef1ec;
}
.preview-scale-control b {
min-width: 36px;
color: #fffdf7;
font-weight: 650;
}
#statusText {
display: inline-block;
min-width: 92px;
line-height: 1;
}
#chunkText {
display: inline-block;
min-width: 70px;
line-height: 1;
}
.stage-stat {
display: inline-flex;
align-items: center;
gap: 5px;
flex: 0 1 auto;
min-width: 0;
color: rgba(232, 234, 223, 0.72);
line-height: 1;
}
.stage-stat b {
display: inline-block;
color: #fffdf7;
font-weight: 650;
font-variant-numeric: tabular-nums;
}
#outputSizeText { min-width: 206px; }
#renderFps { min-width: 2ch; text-align: right; }
#theoreticalFpsText { min-width: 116px; }
#stageLatencyText { min-width: 120px; }
@media (max-width: 1180px) {
.topbar {
flex-wrap: wrap;
height: auto;
min-height: 44px;
padding: 8px 14px;
row-gap: 7px;
}
.topbar-spacer { display: none; }
.preview-scale-control { flex-basis: 170px; min-width: 170px; }
#outputSizeText { min-width: 156px; }
#theoreticalFpsText { min-width: 100px; }
#stageLatencyText { min-width: 108px; }
}
.timeline { justify-content: flex-end; border-top: 1px solid rgba(232, 234, 223, 0.12); }
.dot { width: 8px; height: 8px; border-radius: 50%; background: var(--muted); }
.dot.live { background: #8ecf9d; box-shadow: 0 0 0 4px rgba(142, 207, 157, 0.14); }
.dot.error { background: var(--accent); }
#viewport {
position: relative;
z-index: 1;
display: block;
width: 100%;
height: auto;
max-height: min(calc(56vh * var(--preview-scale, 1.2)), 82vh);
min-height: 0;
object-fit: contain;
image-rendering: auto;
transform: translateZ(0);
}
.preview-overlay {
position: absolute;
inset: 0;
z-index: 3;
display: none;
place-items: center;
pointer-events: none;
background: transparent;
}
.stage[data-preview-state="waiting"] .preview-overlay {
display: grid;
}
.preview-loader {
width: 18px;
height: 18px;
border-radius: 50%;
border: 2px solid rgba(238, 241, 236, 0.22);
border-top-color: rgba(238, 241, 236, 0.82);
animation: previewProgressSpin 0.8s linear infinite;
}
.stage-controls {
display: grid;
grid-template-columns: repeat(2, minmax(180px, 1fr));
gap: 12px;
padding: 12px 14px 13px;
border-top: 1px solid rgba(232, 234, 223, 0.12);
background: #151912;
}
.control-cluster {
display: grid;
grid-template-columns: 46px 1fr;
gap: 10px;
align-items: center;
}
.control-title {
color: rgba(232, 234, 223, 0.62);
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.08em;
}
.stage-controls .camera-pad {
margin: 0;
}
.stage-controls .camera-pad button {
position: relative;
border-color: rgba(232, 234, 223, 0.18);
background: #eef1ec;
color: #11140f;
}
.stage-controls .camera-pad button:active:not(:disabled),
.stage-controls .camera-pad button.is-pressed:not(:disabled),
.stage-controls .camera-pad button.is-key-active:not(:disabled) {
border-color: var(--pressed-border);
background: var(--pressed);
color: #fffdf7;
box-shadow:
inset 0 0 0 1px rgba(255, 253, 247, 0.22),
0 0 0 3px var(--pressed-ring),
0 10px 22px rgba(23, 26, 22, 0.18);
}
.stage-controls .camera-pad button::after {
content: attr(data-key);
position: absolute;
right: 7px;
top: 5px;
color: color-mix(in oklch, var(--muted), var(--ink) 18%);
font-size: 10px;
font-weight: 650;
}
.stage-controls .camera-pad button:active::after,
.stage-controls .camera-pad button.is-pressed::after,
.stage-controls .camera-pad button.is-key-active::after {
color: rgba(255, 253, 247, 0.78);
}
.section-title {
margin: 16px 0 10px;
color: var(--muted);
font-size: 12px;
text-transform: uppercase;
letter-spacing: 0.08em;
}
.reference-upload {
margin-top: 0;
}
.reference-upload input {
border: 1px dashed var(--line);
}
#referencePreview {
display: block;
width: 100%;
aspect-ratio: 16 / 9;
min-height: 0;
border: 1px solid var(--line);
border-radius: 8px;
background: #e5e7df;
}
#referenceName {
min-height: 18px;
color: var(--muted);
font-size: 11px;
}
.spec-grid {
display: grid;
grid-template-columns: repeat(4, minmax(120px, 1fr));
gap: 8px;
margin-bottom: 12px;
}
.spec-grid span {
display: grid;
gap: 2px;
min-height: 46px;
align-content: center;
border: 1px solid var(--line);
border-radius: 8px;
background: #fffdf7;
padding: 9px;
color: var(--muted);
font-size: 11px;
}
.spec-grid b {
color: var(--ink);
font-size: 14px;
}
.presets {
position: static;
max-height: none;
overflow: visible;
scrollbar-gutter: stable;
}
.preset-list {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
gap: 7px;
min-height: 0;
max-height: 230px;
margin-bottom: 12px;
overflow: auto;
padding-right: 3px;
}
.preset {
display: grid;
grid-template-columns: 72px minmax(0, 1fr);
gap: 4px 9px;
align-items: center;
padding: 8px;
border: 1px solid var(--line);
border-radius: 8px;
background: #fffdf7;
text-align: left;
}
.preset-thumb {
display: block;
grid-row: span 2;
width: 72px;
height: 46px;
min-height: 0;
object-fit: cover;
border-radius: 5px;
border: 1px solid color-mix(in oklch, var(--line), var(--ink) 8%);
}
.preset b { min-width: 0; font-size: 13px; }
.preset span { min-width: 0; color: var(--muted); font-size: 11px; line-height: 1.25; }
.preset[data-tone="green"] { border-left: 4px solid var(--green); }
.preset[data-tone="blue"] { border-left: 4px solid var(--blue); }
.preset[data-tone="accent"] { border-left: 4px solid var(--accent); }
.preset:hover:not(:disabled) {
background: color-mix(in oklch, #fffdf7, var(--blue) 9%);
}
.camera-pad {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 6px;
margin-bottom: 8px;
}
.camera-pad span { min-height: 36px; }
.camera-pad button { min-height: 36px; font-size: 12px; }
.telemetry { display: grid; gap: 7px; margin-top: 10px; }
.telemetry span {
display: flex;
justify-content: space-between;
border-bottom: 1px solid var(--line);
padding-bottom: 8px;
color: var(--muted);
font-size: 12px;
}
.telemetry b { color: var(--ink); font-weight: 650; }
.stage-telemetry {
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 0;
margin-top: 0;
border-top: 1px solid rgba(232, 234, 223, 0.12);
background: #11140f;
}
.stage-telemetry span {
min-height: 36px;
align-items: center;
gap: 8px;
border-right: 1px solid rgba(232, 234, 223, 0.1);
border-bottom: 1px solid rgba(232, 234, 223, 0.1);
padding: 0 14px;
color: rgba(232, 234, 223, 0.62);
font-size: 11px;
}
.stage-telemetry b {
color: #fffdf7;
font-size: 12px;
}
.history-list {
display: grid;
gap: 7px;
max-height: 92px;
overflow: auto;
}
.history-list span {
display: block;
border-left: 3px solid var(--blue);
background: #fffdf7;
padding: 8px 9px;
color: var(--muted);
font-size: 12px;
}
@media (max-width: 980px) {
.shell { grid-template-columns: 1fr; }
.presets { position: static; max-height: none; overflow: visible; }
.spec-grid { grid-template-columns: repeat(2, 1fr); }
.preset-list { min-height: 260px; max-height: 420px; }
#viewport { max-height: 420px; }
.stage-controls { grid-template-columns: 1fr; }
.stage-telemetry { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.topbar { flex-wrap: wrap; height: auto; min-height: 44px; padding: 10px 14px; }
.topbar-spacer { display: none; }
.preview-scale-control { min-width: 160px; }
}
@keyframes previewProgressSpin {
to { transform: rotate(360deg); }
}
@@ -0,0 +1,58 @@
# SGLang Diffusion WebUI User Guide
SGLang Diffusion WebUI provides an intuitive Gradio-based interface for image and video generation, supporting parameter
tuning and real-time previews.
## Prerequisites
The WebUI runs on Gradio. To get started, install Gradio first:
```bash
pip install gradio==6.1.0
```
## Launch WebUI Service
SGLang Diffusion now includes an integrated WebUI. Simply add the `--webui` parameter when starting the service.
### Launch Text-to-Image Service
```bash
sglang serve --model-path black-forest-labs/FLUX.1-dev --num-gpus 1 --webui --webui-port 2333
```
### Launch Text-to-Video Service
```bash
sglang serve --model-path Wan-AI/Wan2.2-T2V-A14B-Diffusers --num-gpus 1 --webui --webui-port 2333
```
### Launch Image-to-Image Service
```bash
sglang serve --model-path Qwen/Qwen-Image-Edit-2511 --num-gpus 1 --webui --webui-port 2333
```
### Launch Image-to-Video Service
```bash
sglang serve --model-path Wan-AI/Wan2.2-TI2V-5B-Diffusers --num-gpus 1 --webui --webui-port 2333
```
## Port Forwarding
Once the WebUI service is running, you need to use **SSH port forwarding** to securely access the remote service from
your local machine.
In most cases: Your IDE (like VS Code, Cursor, etc.) can handle this automatically. Check your IDE's remote development
or port forwarding features. Otherwise, execute this command manually.
```bash
ssh -L ${WEBUI_PORT}:localhost:${WEBUI_PORT} user_name@machine_name
```
Learn more about port forwarding: [Port Forwarding](https://en.wikipedia.org/wiki/Port_forwarding).
## Interface Instructions
You can view your model path and task name directly in the UI. We'd appreciate any feedback you'd like to share.
Once launched, access the interface at `http://localhost:${WEBUI_PORT}` in your browser.
@@ -0,0 +1,3 @@
from .main import run_sgl_diffusion_webui
__all__ = ["run_sgl_diffusion_webui"]
@@ -0,0 +1,265 @@
import argparse
import os
from sglang.multimodal_gen.configs.sample.sampling_params import (
DataType,
SamplingParams,
)
from sglang.multimodal_gen.runtime.entrypoints.utils import (
post_process_sample,
prepare_request,
)
from sglang.multimodal_gen.runtime.scheduler_client import sync_scheduler_client
from sglang.multimodal_gen.runtime.server_args import ServerArgs
from sglang.multimodal_gen.runtime.utils.logging_utils import init_logger
from sglang.srt.environ import envs
logger = init_logger(__name__)
def add_webui_args(parser: argparse.ArgumentParser):
"""Add the arguments for the generate command."""
parser = ServerArgs.add_cli_args(parser)
parser = SamplingParams.add_cli_args(parser)
return parser
def run_sgl_diffusion_webui(server_args: ServerArgs):
# import gradio in function to avoid CI crash
import gradio as gr
def resolve_model_repo_id(model_path: str) -> str:
from pathlib import Path
from huggingface_hub.utils import HFValidationError, validate_repo_id
try:
validate_repo_id(model_path)
return model_path
except HFValidationError:
pass
p = Path(model_path).expanduser()
parts = p.parts
if len(parts) < 2:
raise ValueError(f"Invalid model_path: {model_path}")
candidate = f"{parts[-2]}/{parts[-1]}"
validate_repo_id(candidate) # let it raise if invalid
return candidate
# Prefer the hub pipeline tag for Hub models; fall back to the loaded pipeline's
# own task_type for local checkpoints (e.g. a single .safetensors path), which
# have no hub repo to query.
task_name = None
try:
repo_id = resolve_model_repo_id(server_args.model_path)
if envs.SGLANG_USE_MODELSCOPE.get():
from modelscope.hub.api import HubApi
api = HubApi()
model_info_obj = api.model_info(repo_id)
task_name = model_info_obj.tasks[0]["Name"].replace("-synthesis", "")
else:
from huggingface_hub import model_info
task_name = model_info(repo_id).pipeline_tag
except Exception as e:
logger.info(
"Could not resolve task from the model hub (%s); using the loaded "
"pipeline's task_type.",
e,
)
# init client
sync_scheduler_client.initialize(server_args)
if task_name in ("text-to-video", "image-to-video", "video-to-video"):
task_type = "video"
elif task_name in ("text-to-image", "image-to-image"):
task_type = "image"
else:
task_type = (
"image" if server_args.pipeline_config.task_type.is_image_gen() else "video"
)
task_name = task_name or server_args.pipeline_config.task_type.name
video_visible_only = task_type == "video"
image_visible_only = task_type == "image"
# server_args will be reused in gradio_generate function
def gradio_generate(
prompt,
negative_prompt,
reference_image_paths_str,
seed,
num_frames,
frames_per_second,
width,
height,
num_inference_steps,
guidance_scale,
enable_teacache,
):
"""
NOTE: The input and output of function which is called by gradio button must be gradio components
So we use global variable sampling_params_kwargs to avoid pass this param, because gradio does not support this.
return [ np.ndarray, None ] | [None, np.ndarray]
"""
if reference_image_paths_str:
if "" in reference_image_paths_str:
logger.warning(
f"Warning: please use English comma to separate the reference image paths, and the reference image paths is: {reference_image_paths_str}"
)
reference_image_paths_str = reference_image_paths_str.replace("", ",")
image_path = [path.strip() for path in reference_image_paths_str.split(",")]
else:
image_path = None
sampling_params_kwargs = dict(
prompt=prompt,
negative_prompt=negative_prompt,
image_path=image_path,
seed=seed,
num_frames=num_frames,
fps=frames_per_second,
width=width,
height=height,
guidance_scale=guidance_scale,
num_inference_steps=num_inference_steps,
enable_teacache=enable_teacache,
return_file_paths_only=False,
)
sampling_params = SamplingParams.from_user_sampling_params_args(
server_args.model_path,
server_args=server_args,
**sampling_params_kwargs,
)
batch = prepare_request(
server_args=server_args,
sampling_params=sampling_params,
)
result = sync_scheduler_client.forward([batch])
save_file_path = str(os.path.join(batch.output_path, batch.output_file_name))
if result.output is None:
sampling_params_str = "\n".join(
[f"{key}: {value}" for key, value in sampling_params_kwargs.items()]
)
no_output_msg = f"No output is generated by client, and their sampling params is: {sampling_params_str}"
if batch.data_type == DataType.VIDEO:
if os.path.exists(save_file_path):
logger.warning(no_output_msg)
return None, save_file_path
else:
no_output_msg += f"\nAnd the expected output file was not found at: {save_file_path}"
raise ValueError(no_output_msg)
else:
raise ValueError(no_output_msg)
frames = post_process_sample(
result.output[0],
batch.data_type,
batch.fps,
batch.save_output,
save_file_path,
)
if batch.data_type == DataType.VIDEO:
# gradio video need video path to show video
return None, save_file_path
else:
return frames[0], None
with gr.Blocks() as demo:
gr.Markdown("# 🚀 SGLang Diffusion Application")
with gr.Row():
gr.Textbox(label="Model", value=server_args.model_path)
gr.Textbox(label="Task name", value=task_name)
with gr.Row():
with gr.Column(scale=4):
prompt = gr.Textbox(label="Prompt", value="A curious raccoon")
negative_prompt = gr.Textbox(
label="Negative_prompt",
value="Bright tones, overexposed, static, blurred details, subtitles, style, works, paintings, images, static, overall gray, worst quality, low quality, JPEG compression residue, ugly, incomplete, extra fingers, poorly drawn hands, poorly drawn faces, deformed, disfigured, misshapen limbs, fused fingers, still picture, messy background, three legs, many people in the background, walking backwards",
)
with gr.Column(scale=1):
seed = gr.Number(label="seed", precision=0, value=1234)
run_btn = gr.Button("Generate", variant="primary", size="lg")
with gr.Row():
with gr.Column():
width = gr.Number(label="width", precision=0, value=720)
height = gr.Number(label="height", precision=0, value=480)
num_inference_steps = gr.Slider(
minimum=0, maximum=50, value=20, step=1, label="num_inference_steps"
)
guidance_scale = gr.Slider(
minimum=0.0, maximum=10, value=5, step=0.01, label="guidance_scale"
)
num_frames = gr.Slider(
minimum=1,
maximum=181,
value=81,
step=1,
label="num_frames",
visible=video_visible_only,
)
frames_per_second = gr.Slider(
minimum=4,
maximum=60,
value=16,
step=1,
label="frames_per_second",
visible=video_visible_only,
)
reference_image_paths_str = gr.Textbox(
label="reference images",
placeholder="Examples: 'image1.png, image2.png' or 'https://example.com/image1.png, https://example.com/image2.png'",
)
enable_teacache = gr.Checkbox(label="enable_teacache", value=False)
with gr.Column():
image_out = gr.Image(
label="Generated Image", visible=image_visible_only, format="png"
)
video_out = gr.Video(
label="Generated Video", visible=video_visible_only
)
run_btn.click(
fn=gradio_generate,
inputs=[
prompt,
negative_prompt,
reference_image_paths_str,
seed,
num_frames,
frames_per_second,
width,
height,
num_inference_steps,
guidance_scale,
enable_teacache,
],
outputs=[image_out, video_out],
)
_, local_url, _ = demo.launch(
server_port=server_args.webui_port,
quiet=True,
prevent_thread_lock=True,
show_error=True,
)
# print banner
delimiter = "=" * 80
url = local_url or f"http://localhost:{server_args.webui_port}"
print(f"""
{delimiter}
\033[1mSGLang Diffusion WebUI available at:\033[0m \033[1;4;92m{url}\033[0m
{delimiter}
""")
demo.block_thread()