[diffusion] refactor: unify SamplingParams construction and improve DiffGenerator return types (#18928)

This commit is contained in:
Mick
2026-02-18 14:56:58 +08:00
committed by GitHub
parent 9d138685c1
commit 420a611275
12 changed files with 478 additions and 537 deletions
@@ -352,6 +352,9 @@ class PipelineConfig:
# Pad to next multiple of SP degree if needed # Pad to next multiple of SP degree if needed
if time_dim > 0 and time_dim % sp_world_size != 0: if time_dim > 0 and time_dim % sp_world_size != 0:
logger.debug(
"Padding latents to next multiple of SP degree, performance is sub-optimal"
)
pad_len = sp_world_size - (time_dim % sp_world_size) pad_len = sp_world_size - (time_dim % sp_world_size)
pad = torch.zeros( pad = torch.zeros(
(*latents.shape[:2], pad_len, *latents.shape[3:]), (*latents.shape[:2], pad_len, *latents.shape[3:]),
@@ -21,7 +21,6 @@ from sglang.multimodal_gen.runtime.entrypoints.cli.utils import (
from sglang.multimodal_gen.runtime.server_args import ServerArgs from sglang.multimodal_gen.runtime.server_args import ServerArgs
from sglang.multimodal_gen.runtime.utils.logging_utils import init_logger from sglang.multimodal_gen.runtime.utils.logging_utils import init_logger
from sglang.multimodal_gen.runtime.utils.perf_logger import ( from sglang.multimodal_gen.runtime.utils.perf_logger import (
MemorySnapshot,
PerformanceLogger, PerformanceLogger,
RequestMetrics, RequestMetrics,
) )
@@ -65,11 +64,13 @@ def maybe_dump_performance(args: argparse.Namespace, server_args, prompt: str, r
return return
if isinstance(results, list): if isinstance(results, list):
result = results[0] if results else {} result = results[0] if results else None
else: else:
result = results result = results
timings_dict = result.get("timings") timings_dict = getattr(result, "timings", None) or (
result.get("timings") if isinstance(result, dict) else None
)
if not (args.perf_dump_path and timings_dict): if not (args.perf_dump_path and timings_dict):
return return
@@ -78,17 +79,6 @@ def maybe_dump_performance(args: argparse.Namespace, server_args, prompt: str, r
timings.steps = timings_dict.get("steps", []) timings.steps = timings_dict.get("steps", [])
timings.total_duration_ms = timings_dict.get("total_duration_ms", 0) timings.total_duration_ms = timings_dict.get("total_duration_ms", 0)
# restore memory snapshots from serialized dict
memory_snapshots_dict = timings_dict.get("memory_snapshots", {})
for checkpoint_name, snapshot_dict in memory_snapshots_dict.items():
snapshot = MemorySnapshot(
allocated_mb=snapshot_dict.get("allocated_mb", 0.0),
reserved_mb=snapshot_dict.get("reserved_mb", 0.0),
peak_allocated_mb=snapshot_dict.get("peak_allocated_mb", 0.0),
peak_reserved_mb=snapshot_dict.get("peak_reserved_mb", 0.0),
)
timings.memory_snapshots[checkpoint_name] = snapshot
PerformanceLogger.dump_benchmark_report( PerformanceLogger.dump_benchmark_report(
file_path=args.perf_dump_path, file_path=args.perf_dump_path,
timings=timings, timings=timings,
@@ -13,18 +13,15 @@ import os
import time import time
from typing import Any, List, Union from typing import Any, List, Union
import numpy as np
from sglang.multimodal_gen.configs.sample.sampling_params import SamplingParams from sglang.multimodal_gen.configs.sample.sampling_params import SamplingParams
from sglang.multimodal_gen.runtime.entrypoints.openai.utils import ( from sglang.multimodal_gen.runtime.entrypoints.utils import (
GenerationResult,
ListLorasReq, ListLorasReq,
MergeLoraWeightsReq, MergeLoraWeightsReq,
SetLoraReq, SetLoraReq,
ShutdownReq, ShutdownReq,
UnmergeLoraWeightsReq, UnmergeLoraWeightsReq,
format_lora_message, format_lora_message,
)
from sglang.multimodal_gen.runtime.entrypoints.utils import (
prepare_request, prepare_request,
save_outputs, save_outputs,
) )
@@ -157,65 +154,33 @@ class DiffGenerator:
def generate( def generate(
self, self,
sampling_params_kwargs: dict | None = None, sampling_params_kwargs: dict | None = None,
) -> dict[str, Any] | list[np.ndarray] | list[dict[str, Any]] | None: ) -> GenerationResult | list[GenerationResult] | None:
""" """Generate image(s)/video(s) based on the given prompt(s).
Generate a image/video based on the given prompt.
Args: Returns a single GenerationResult for a single prompt, a list for
multiple prompts, or None when every request failed.
Returns:
Either the output dictionary, list of frames, or list of results for batch processing
""" """
# 1. prepare requests # 1. prepare requests
prompt = sampling_params_kwargs.get("prompt", None) prompts = self._resolve_prompts(sampling_params_kwargs.get("prompt"))
prompts: list[str] = []
# Handle batch processing from text file
if self.server_args.prompt_file_path is not None:
prompt_txt_path = self.server_args.prompt_file_path
if not os.path.exists(prompt_txt_path):
raise FileNotFoundError(
f"Prompt text file not found: {prompt_txt_path}"
)
# Read prompts from file
with open(prompt_txt_path, encoding="utf-8") as f:
prompts.extend(line.strip() for line in f if line.strip())
if not prompts:
raise ValueError(f"No prompts found in file: {prompt_txt_path}")
logger.info("Found %d prompts in %s", len(prompts), prompt_txt_path)
else:
if prompt is None:
prompt = " "
if isinstance(prompt, str):
prompts.append(prompt)
elif isinstance(prompt, list):
prompts.extend(prompt)
sampling_params = SamplingParams.from_user_sampling_params_args( sampling_params = SamplingParams.from_user_sampling_params_args(
self.server_args.model_path, self.server_args.model_path,
server_args=self.server_args, server_args=self.server_args,
**sampling_params_kwargs, **sampling_params_kwargs,
) )
# Extract diffusers_kwargs if passed
diffusers_kwargs = sampling_params_kwargs.pop("diffusers_kwargs", None)
requests: list[Req] = [] requests: list[Req] = []
for output_idx, p in enumerate(prompts): for p in prompts:
sampling_params.prompt = p sampling_params.prompt = p
req = prepare_request( req = prepare_request(
server_args=self.server_args, server_args=self.server_args,
sampling_params=sampling_params, sampling_params=sampling_params,
) )
# Add diffusers_kwargs to request's extra dict
if diffusers_kwargs:
req.extra["diffusers_kwargs"] = diffusers_kwargs
requests.append(req) requests.append(req)
results = [] results: list[GenerationResult] = []
total_start_time = time.perf_counter() total_start_time = time.perf_counter()
# 2. send requests to scheduler, one at a time # 2. send requests to scheduler one at a time
# TODO: send batch when supported # TODO: send batch when supported
for request_idx, req in enumerate(requests): for request_idx, req in enumerate(requests):
try: try:
@@ -235,102 +200,115 @@ class DiffGenerator:
request_idx + 1, request_idx + 1,
) )
continue continue
audio_sample_rate = output_batch.audio_sample_rate
common = dict(
prompt=req.prompt,
size=(req.height, req.width, req.num_frames),
generation_time=timer.duration,
peak_memory_mb=output_batch.peak_memory_mb,
timings=(
output_batch.timings.to_dict()
if output_batch.timings
else {}
),
trajectory_latents=output_batch.trajectory_latents,
trajectory_timesteps=output_batch.trajectory_timesteps,
trajectory_decoded=output_batch.trajectory_decoded,
)
if req.save_output and req.return_file_paths_only: if req.save_output and req.return_file_paths_only:
for output_idx, output_path in enumerate( for idx, path in enumerate(output_batch.output_file_paths):
output_batch.output_file_paths results.append(
): GenerationResult(
result_item: dict[str, Any] = { **common,
"samples": None, prompt_index=idx,
"frames": None, output_file_path=path,
"audio": None, )
"prompts": req.prompt, )
"size": (req.height, req.width, req.num_frames),
"generation_time": timer.duration,
"peak_memory_mb": output_batch.peak_memory_mb,
"timings": (
output_batch.timings.to_dict()
if output_batch.timings
else {}
),
"trajectory": output_batch.trajectory_latents,
"trajectory_timesteps": output_batch.trajectory_timesteps,
"trajectory_decoded": output_batch.trajectory_decoded,
"prompt_index": output_idx,
"output_file_path": output_path,
}
results.append(result_item)
continue continue
samples_out: list[Any] = [] samples_out: list[Any] = []
audios_out: list[Any] = [] audios_out: list[Any] = []
frames_out: list[Any] = [] frames_out: list[Any] = []
num_outputs = len(output_batch.output)
save_outputs( save_outputs(
output_batch.output, output_batch.output,
req.data_type, req.data_type,
req.fps, req.fps,
req.save_output, req.save_output,
lambda idx: req.output_file_path(len(output_batch.output), idx), lambda idx: req.output_file_path(num_outputs, idx),
audio=output_batch.audio, audio=output_batch.audio,
audio_sample_rate=audio_sample_rate, audio_sample_rate=output_batch.audio_sample_rate,
samples_out=samples_out, samples_out=samples_out,
audios_out=audios_out, audios_out=audios_out,
frames_out=frames_out, frames_out=frames_out,
output_compression=req.output_compression, output_compression=req.output_compression,
) )
for output_idx in range(len(samples_out)): for idx in range(len(samples_out)):
result_item: dict[str, Any] = { results.append(
"samples": samples_out[output_idx], GenerationResult(
"frames": frames_out[output_idx], **common,
"audio": audios_out[output_idx], samples=samples_out[idx],
"prompts": req.prompt, frames=frames_out[idx],
"size": (req.height, req.width, req.num_frames), audio=audios_out[idx],
"generation_time": timer.duration, prompt_index=idx,
"peak_memory_mb": output_batch.peak_memory_mb, output_file_path=req.output_file_path(num_outputs, idx),
"timings": ( )
output_batch.timings.to_dict() )
if output_batch.timings except Exception as e:
else {} logger.error(
), "Generation failed for prompt %d/%d: %s",
"trajectory": output_batch.trajectory_latents, request_idx + 1,
"trajectory_timesteps": output_batch.trajectory_timesteps, len(requests),
"trajectory_decoded": output_batch.trajectory_decoded, e,
"prompt_index": output_idx, exc_info=True,
} )
results.append(result_item)
except Exception:
continue continue
total_gen_time = time.perf_counter() - total_start_time total_gen_time = time.perf_counter() - total_start_time
log_batch_completion(logger, len(results), total_gen_time) log_batch_completion(logger, len(results), total_gen_time)
self._log_summary(results)
if results: if not results:
if self.server_args.warmup:
total_duration_ms = results[0]["timings"]["total_duration_ms"]
logger.info(
f"Warmed-up request processed in {GREEN}%.2f{RESET} seconds (with warmup excluded)",
total_duration_ms / 1000.0,
)
peak_memories = [r.get("peak_memory_mb", 0) for r in results]
if peak_memories:
max_peak_memory = max(peak_memories)
avg_peak_memory = sum(peak_memories) / len(peak_memories)
logger.info(
f"Memory usage - Max peak: {max_peak_memory:.2f} MB, "
f"Avg peak: {avg_peak_memory:.2f} MB"
)
if len(results) == 0:
return None return None
else: return results[0] if len(results) == 1 else results
if requests[0].return_frames:
results = [r["frames"] for r in results] def _resolve_prompts(self, prompt: str | list[str] | None) -> list[str]:
if len(results) == 1: """Collect prompts from the argument or from a prompt file."""
return results[0] if self.server_args.prompt_file_path is not None:
return results path = self.server_args.prompt_file_path
if not os.path.exists(path):
raise FileNotFoundError(f"Prompt text file not found: {path}")
with open(path, encoding="utf-8") as f:
prompts = [line.strip() for line in f if line.strip()]
if not prompts:
raise ValueError(f"No prompts found in file: {path}")
logger.info("Found %d prompts in %s", len(prompts), path)
return prompts
if prompt is None:
return [" "]
if isinstance(prompt, str):
return [prompt]
return list(prompt)
def _log_summary(self, results: list[GenerationResult]) -> None:
if not results:
return
if self.server_args.warmup:
total_duration_ms = results[0].timings.get("total_duration_ms", 0)
logger.info(
f"Warmed-up request processed in {GREEN}%.2f{RESET} seconds (with warmup excluded)",
total_duration_ms / 1000.0,
)
peak_memories = [r.peak_memory_mb for r in results if r.peak_memory_mb]
if peak_memories:
logger.info(
f"Memory usage - Max peak: {max(peak_memories):.2f} MB, "
f"Avg peak: {sum(peak_memories) / len(peak_memories):.2f} MB"
)
def _send_to_scheduler_and_wait_for_response(self, batch: list[Req]) -> OutputBatch: def _send_to_scheduler_and_wait_for_response(self, batch: list[Req]) -> OutputBatch:
""" """
@@ -415,20 +393,15 @@ class DiffGenerator:
"Failed to merge LoRA weights", "Failed to merge LoRA weights",
) )
def list_loras(self) -> OutputBatch: def list_loras(self) -> dict:
""" """List loaded LoRA adapters and current application status per module."""
List loaded LoRA adapters and current application status per module.
"""
output = self._send_lora_request( output = self._send_lora_request(
req=ListLorasReq(), req=ListLorasReq(),
success_msg="Successfully listed LoRA adapters", success_msg="Successfully listed LoRA adapters",
failure_msg="Failed to list LoRA adapters", failure_msg="Failed to list LoRA adapters",
) )
if output.error is None: # _send_lora_request already raises on error, so output.error is always None here
return output.output or {} return output.output or {}
else:
raise RuntimeError(f"Failed to list LoRA adapters: {output.error}")
def _ensure_lora_state( def _ensure_lora_state(
self, self,
@@ -505,6 +478,9 @@ class DiffGenerator:
sync_scheduler_client.close() sync_scheduler_client.close()
self.owns_scheduler_client = False self.owns_scheduler_client = False
def __enter__(self):
return self
def __exit__(self, exc_type, exc_val, exc_tb): def __exit__(self, exc_type, exc_val, exc_tb):
self.shutdown() self.shutdown()
@@ -5,6 +5,7 @@ import base64
import os import os
import uuid import uuid
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from typing import TYPE_CHECKING
import torch import torch
from fastapi import APIRouter, FastAPI, Request from fastapi import APIRouter, FastAPI, Request
@@ -15,12 +16,19 @@ from sglang.multimodal_gen.runtime.entrypoints.openai import image_api, video_ap
from sglang.multimodal_gen.runtime.entrypoints.openai.protocol import ( from sglang.multimodal_gen.runtime.entrypoints.openai.protocol import (
VertexGenerateReqInput, VertexGenerateReqInput,
) )
from sglang.multimodal_gen.runtime.entrypoints.openai.utils import build_sampling_params
from sglang.multimodal_gen.runtime.entrypoints.utils import ( from sglang.multimodal_gen.runtime.entrypoints.utils import (
prepare_request, prepare_request,
save_outputs, save_outputs,
) )
from sglang.multimodal_gen.runtime.scheduler_client import async_scheduler_client from sglang.multimodal_gen.runtime.scheduler_client import async_scheduler_client
from sglang.multimodal_gen.runtime.server_args import ServerArgs, get_global_server_args from sglang.multimodal_gen.runtime.server_args import ServerArgs, get_global_server_args
from sglang.multimodal_gen.runtime.utils.logging_utils import init_logger
if TYPE_CHECKING:
from sglang.multimodal_gen.runtime.pipelines_core.schedule_batch import Req
logger = init_logger(__name__)
DEFAULT_SEED = 1024 DEFAULT_SEED = 1024
VERTEX_ROUTE = os.environ.get("AIP_PREDICT_ROUTE", "/vertex_generate") VERTEX_ROUTE = os.environ.get("AIP_PREDICT_ROUTE", "/vertex_generate")
@@ -43,7 +51,7 @@ async def lifespan(app: FastAPI):
yield yield
# On shutdown # On shutdown
print("FastAPI app is shutting down...") logger.info("FastAPI app is shutting down...")
broker_task.cancel() broker_task.cancel()
async_scheduler_client.close() async_scheduler_client.close()
@@ -110,7 +118,10 @@ def encode_video_to_base64(file_path: str):
return base64.b64encode(f.read()).decode("utf-8") return base64.b64encode(f.read()).decode("utf-8")
async def forward_to_scheduler(req_obj, sp): async def forward_to_scheduler(
req_obj: "Req",
sp: SamplingParams,
):
"""Forwards request to scheduler and processes the result.""" """Forwards request to scheduler and processes the result."""
try: try:
response = await async_scheduler_client.forward(req_obj) response = await async_scheduler_client.forward(req_obj)
@@ -137,7 +148,7 @@ async def forward_to_scheduler(req_obj, sp):
data = response if isinstance(response, dict) else vars(response) data = response if isinstance(response, dict) else vars(response)
if output_file_path: if output_file_path:
print(f"Processing output file: {output_file_path}") logger.info("Processing output file: %s", output_file_path)
b64_video = encode_video_to_base64(output_file_path) b64_video = encode_video_to_base64(output_file_path)
if b64_video: if b64_video:
@@ -148,7 +159,7 @@ async def forward_to_scheduler(req_obj, sp):
return make_serializable(data) return make_serializable(data)
except Exception as e: except Exception as e:
print(f"Error during generation: {e}") logger.error("Error during generation: %s", e, exc_info=True)
return {"error": str(e)} return {"error": str(e)}
@@ -168,32 +179,17 @@ async def vertex_generate(vertex_req: VertexGenerateReqInput):
for inst in vertex_req.instances: for inst in vertex_req.instances:
rid = f"vertex_{uuid.uuid4()}" rid = f"vertex_{uuid.uuid4()}"
prompt = inst.get("prompt") or inst.get("text") sp = build_sampling_params(
image_input = inst.get("image") or inst.get("image_url") rid,
seed_val = params.get("seed", DEFAULT_SEED) prompt=inst.get("prompt") or inst.get("text"),
image_path=inst.get("image") or inst.get("image_url"),
# Create a dictionary of provided parameters seed=params.get("seed", DEFAULT_SEED),
# This filters out None values so the dataclass defaults kick in num_frames=params.get("num_frames"),
user_params = { fps=params.get("fps"),
"num_frames": params.get("num_frames"), width=params.get("width"),
"fps": params.get("fps"), height=params.get("height"),
"width": params.get("width"), guidance_scale=params.get("guidance_scale"),
"height": params.get("height"), save_output=params.get("save_output"),
"guidance_scale": params.get("guidance_scale"),
"save_output": params.get("save_output"),
}
# Remove None values to allow SamplingParams defaults to take over
valid_params = {k: v for k, v in user_params.items() if v is not None}
sp = SamplingParams.from_user_sampling_params_args(
model_path=server_args.model_path,
request_id=rid,
prompt=prompt,
image_path=image_input,
seed=seed_val,
server_args=server_args,
**valid_params, # Unpack the filtered dictionary
) )
backend_req = prepare_request(server_args, sampling_params=sp) backend_req = prepare_request(server_args, sampling_params=sp)
@@ -6,7 +6,7 @@ from fastapi.responses import ORJSONResponse
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
from sglang.multimodal_gen.registry import get_model_info from sglang.multimodal_gen.registry import get_model_info
from sglang.multimodal_gen.runtime.entrypoints.openai.utils import ( from sglang.multimodal_gen.runtime.entrypoints.utils import (
ListLorasReq, ListLorasReq,
MergeLoraWeightsReq, MergeLoraWeightsReq,
SetLoraReq, SetLoraReq,
@@ -8,10 +8,7 @@ from typing import List, Optional
from fastapi import APIRouter, File, Form, HTTPException, Path, Query, UploadFile from fastapi import APIRouter, File, Form, HTTPException, Path, Query, UploadFile
from fastapi.responses import FileResponse from fastapi.responses import FileResponse
from sglang.multimodal_gen.configs.sample.sampling_params import ( from sglang.multimodal_gen.configs.sample.sampling_params import generate_request_id
SamplingParams,
generate_request_id,
)
from sglang.multimodal_gen.runtime.entrypoints.openai.protocol import ( from sglang.multimodal_gen.runtime.entrypoints.openai.protocol import (
ImageGenerationsRequest, ImageGenerationsRequest,
ImageResponse, ImageResponse,
@@ -20,14 +17,15 @@ from sglang.multimodal_gen.runtime.entrypoints.openai.protocol import (
from sglang.multimodal_gen.runtime.entrypoints.openai.storage import cloud_storage from sglang.multimodal_gen.runtime.entrypoints.openai.storage import cloud_storage
from sglang.multimodal_gen.runtime.entrypoints.openai.stores import IMAGE_STORE from sglang.multimodal_gen.runtime.entrypoints.openai.stores import IMAGE_STORE
from sglang.multimodal_gen.runtime.entrypoints.openai.utils import ( from sglang.multimodal_gen.runtime.entrypoints.openai.utils import (
_parse_size,
add_common_data_to_response, add_common_data_to_response,
adjust_output_quality, build_sampling_params,
choose_output_image_ext,
merge_image_input_list, merge_image_input_list,
process_generation_batch, process_generation_batch,
save_image_to_path, save_image_to_path,
) )
from sglang.multimodal_gen.runtime.entrypoints.utils import prepare_request from sglang.multimodal_gen.runtime.entrypoints.utils import prepare_request
from sglang.multimodal_gen.runtime.pipelines_core.schedule_batch import OutputBatch
from sglang.multimodal_gen.runtime.scheduler_client import async_scheduler_client from sglang.multimodal_gen.runtime.scheduler_client import async_scheduler_client
from sglang.multimodal_gen.runtime.server_args import get_global_server_args from sglang.multimodal_gen.runtime.server_args import get_global_server_args
from sglang.multimodal_gen.runtime.utils.logging_utils import init_logger from sglang.multimodal_gen.runtime.utils.logging_utils import init_logger
@@ -36,92 +34,82 @@ router = APIRouter(prefix="/v1/images", tags=["images"])
logger = init_logger(__name__) logger = init_logger(__name__)
def _choose_ext(output_format: Optional[str], background: Optional[str]) -> str: def _read_b64_for_paths(paths: list[str]) -> list[str]:
# Normalize and choose extension """Read and base64-encode each file. Must be called before cloud upload deletes them."""
fmt = (output_format or "").lower() result = []
if fmt in {"png", "webp", "jpeg", "jpg"}: for path in paths:
return "jpg" if fmt == "jpeg" else fmt with open(path, "rb") as f:
# If transparency requested, prefer png result.append(base64.b64encode(f.read()).decode("utf-8"))
if (background or "auto").lower() == "transparent": return result
return "png"
# Default
return "jpg"
def _build_sampling_params_from_request( def _build_image_response_kwargs(
request_id: str, save_file_path_list: list[str],
resp_format: str,
prompt: str, prompt: str,
n: int, request_id: str,
size: Optional[str], result: OutputBatch,
output_format: Optional[str], *,
background: Optional[str], b64_list: list[str] | None = None,
image_path: Optional[list[str]] = None, cloud_url: str | None = None,
seed: Optional[int] = None, fallback_url: str | None = None,
generator_device: Optional[str] = None, ) -> dict:
num_inference_steps: Optional[int] = None, """Build ImageResponse data list.
guidance_scale: Optional[float] = None,
true_cfg_scale: Optional[float] = None, For b64_json: uses pre-read b64_list (call _read_b64_for_paths first).
negative_prompt: Optional[str] = None, For url: uses cloud_url or fallback_url.
enable_teacache: Optional[bool] = None, """
num_frames: int = 1, ret = None
output_compression: Optional[int] = None, if resp_format == "b64_json":
) -> SamplingParams: if not b64_list:
if size is None: raise ValueError("b64_list required for b64_json response_format")
width, height = None, None data = [
ImageResponseData(
b64_json=b64,
revised_prompt=prompt,
file_path=os.path.abspath(path),
)
for b64, path in zip(b64_list, save_file_path_list)
]
ret = {"data": data}
elif resp_format == "url":
url = cloud_url or fallback_url
if not url:
raise HTTPException(
status_code=400,
detail="response_format='url' requires cloud storage to be configured.",
)
ret = {
"data": [
ImageResponseData(
url=url,
revised_prompt=prompt,
file_path=os.path.abspath(save_file_path_list[0]),
)
],
}
else: else:
width, height = _parse_size(size) raise HTTPException(
ext = _choose_ext(output_format, background) status_code=400, detail=f"response_format={resp_format} is not supported"
)
server_args = get_global_server_args() ret = add_common_data_to_response(ret, request_id=request_id, result=result)
sampling_params = SamplingParams.from_user_sampling_params_args(
model_path=server_args.model_path,
request_id=request_id,
prompt=prompt,
image_path=image_path,
num_frames=num_frames,
width=width,
height=height,
num_outputs_per_prompt=max(1, min(int(n or 1), 10)),
save_output=True,
server_args=server_args,
output_file_name=f"{request_id}.{ext}",
seed=seed,
generator_device=generator_device,
num_inference_steps=num_inference_steps,
enable_teacache=enable_teacache,
**({"guidance_scale": guidance_scale} if guidance_scale is not None else {}),
**({"negative_prompt": negative_prompt} if negative_prompt is not None else {}),
**({"true_cfg_scale": true_cfg_scale} if true_cfg_scale is not None else {}),
**(
{"output_compression": output_compression}
if output_compression is not None
else {}
),
)
if num_inference_steps is not None: return ret
sampling_params.num_inference_steps = num_inference_steps
if guidance_scale is not None:
sampling_params.guidance_scale = guidance_scale
if seed is not None:
sampling_params.seed = seed
return sampling_params
@router.post("/generations", response_model=ImageResponse) @router.post("/generations", response_model=ImageResponse)
async def generations( async def generations(
request: ImageGenerationsRequest, request: ImageGenerationsRequest,
): ):
request_id = generate_request_id() request_id = generate_request_id()
sampling = _build_sampling_params_from_request( ext = choose_output_image_ext(request.output_format, request.background)
request_id=request_id, sampling = build_sampling_params(
request_id,
prompt=request.prompt, prompt=request.prompt,
n=request.n or 1,
size=request.size, size=request.size,
output_format=request.output_format, num_outputs_per_prompt=max(1, min(int(request.n or 1), 10)),
background=request.background, output_file_name=f"{request_id}.{ext}",
seed=request.seed, seed=request.seed,
generator_device=request.generator_device, generator_device=request.generator_device,
num_inference_steps=request.num_inference_steps, num_inference_steps=request.num_inference_steps,
@@ -130,37 +118,29 @@ async def generations(
negative_prompt=request.negative_prompt, negative_prompt=request.negative_prompt,
enable_teacache=request.enable_teacache, enable_teacache=request.enable_teacache,
output_compression=request.output_compression, output_compression=request.output_compression,
output_quality=request.output_quality,
) )
batch = prepare_request( batch = prepare_request(
server_args=get_global_server_args(), server_args=get_global_server_args(),
sampling_params=sampling, sampling_params=sampling,
) )
if batch.output_compression is None:
batch.output_compression = adjust_output_quality(
request.output_quality, batch.data_type
)
# Add diffusers_kwargs if provided # Add diffusers_kwargs if provided
if request.diffusers_kwargs: if request.diffusers_kwargs:
batch.extra["diffusers_kwargs"] = request.diffusers_kwargs batch.extra["diffusers_kwargs"] = request.diffusers_kwargs
# Run synchronously for images and save to disk
save_file_path_list, result = await process_generation_batch( save_file_path_list, result = await process_generation_batch(
async_scheduler_client, batch async_scheduler_client, batch
) )
save_file_path = save_file_path_list[0] save_file_path = save_file_path_list[0]
resp_format = (request.response_format or "b64_json").lower() resp_format = (request.response_format or "b64_json").lower()
b64_data = None
# 1. Read content first if needed (while file exists) # read b64 before cloud upload may delete the local file
if resp_format == "b64_json": b64_list = (
with open(save_file_path, "rb") as f: _read_b64_for_paths(save_file_path_list) if resp_format == "b64_json" else None
b64_data = base64.b64encode(f.read()).decode("utf-8") )
# 2. Upload and Delete local file
cloud_url = await cloud_storage.upload_and_cleanup(save_file_path) cloud_url = await cloud_storage.upload_and_cleanup(save_file_path)
# 3. Update Database
await IMAGE_STORE.upsert( await IMAGE_STORE.upsert(
request_id, request_id,
{ {
@@ -171,40 +151,16 @@ async def generations(
}, },
) )
# 4. Return Response response_kwargs = _build_image_response_kwargs(
if resp_format == "b64_json": save_file_path_list,
response_kwargs = { resp_format,
"data": [ request.prompt,
ImageResponseData( request_id,
b64_json=b64_data, result,
revised_prompt=request.prompt, b64_list=b64_list,
) cloud_url=cloud_url,
]
}
elif resp_format == "url":
if not cloud_url:
raise HTTPException(
status_code=400,
detail="response_format='url' requires cloud storage to be configured.",
)
response_kwargs = {
"data": [
ImageResponseData(
url=cloud_url,
revised_prompt=request.prompt,
file_path=os.path.abspath(save_file_path),
)
],
}
else:
# Return error, not supported
raise HTTPException(
status_code=400, detail=f"response_format={resp_format} is not supported"
)
response_kwargs = add_common_data_to_response(
response_kwargs, request_id=request_id, result=result
) )
return ImageResponse(**response_kwargs) return ImageResponse(**response_kwargs)
@@ -245,8 +201,9 @@ async def edits(
) )
# Save all input images; additional images beyond the first are saved for potential future use # Save all input images; additional images beyond the first are saved for potential future use
uploads_dir = os.path.join("outputs", "uploads") uploads_dir = os.path.join("inputs", "uploads")
os.makedirs(uploads_dir, exist_ok=True) os.makedirs(uploads_dir, exist_ok=True)
image_list = merge_image_input_list(images, urls) image_list = merge_image_input_list(images, urls)
input_paths = [] input_paths = []
@@ -262,13 +219,13 @@ async def edits(
status_code=400, detail=f"Failed to process image source: {str(e)}" status_code=400, detail=f"Failed to process image source: {str(e)}"
) )
sampling = _build_sampling_params_from_request( ext = choose_output_image_ext(output_format, background)
request_id=request_id, sampling = build_sampling_params(
request_id,
prompt=prompt, prompt=prompt,
n=n or 1,
size=size, size=size,
output_format=output_format, num_outputs_per_prompt=max(1, min(int(n or 1), 10)),
background=background, output_file_name=f"{request_id}.{ext}",
image_path=input_paths, image_path=input_paths,
seed=seed, seed=seed,
generator_device=generator_device, generator_device=generator_device,
@@ -279,32 +236,25 @@ async def edits(
enable_teacache=enable_teacache, enable_teacache=enable_teacache,
num_frames=num_frames, num_frames=num_frames,
output_compression=output_compression, output_compression=output_compression,
output_quality=output_quality,
) )
batch = prepare_request( batch = prepare_request(
server_args=get_global_server_args(), server_args=get_global_server_args(),
sampling_params=sampling, sampling_params=sampling,
) )
if batch.output_compression is None:
batch.output_compression = adjust_output_quality(
output_quality, batch.data_type
)
save_file_path_list, result = await process_generation_batch( save_file_path_list, result = await process_generation_batch(
async_scheduler_client, batch async_scheduler_client, batch
) )
save_file_path = save_file_path_list[0] save_file_path = save_file_path_list[0]
resp_format = (response_format or "b64_json").lower() resp_format = (response_format or "b64_json").lower()
b64_data = None
# 1. Read content first if needed (while file exists) # read b64 before cloud upload may delete the local file
if resp_format == "b64_json": b64_list = (
with open(save_file_path, "rb") as f: _read_b64_for_paths(save_file_path_list) if resp_format == "b64_json" else None
b64_data = base64.b64encode(f.read()).decode("utf-8") )
# 2. Upload and Delete local file
cloud_url = await cloud_storage.upload_and_cleanup(save_file_path) cloud_url = await cloud_storage.upload_and_cleanup(save_file_path)
# 3. Update Database
await IMAGE_STORE.upsert( await IMAGE_STORE.upsert(
request_id, request_id,
{ {
@@ -312,43 +262,22 @@ async def edits(
"created_at": int(time.time()), "created_at": int(time.time()),
"file_path": None if cloud_url else save_file_path, "file_path": None if cloud_url else save_file_path,
"url": cloud_url, "url": cloud_url,
"input_image_paths": input_paths, # Store all input image paths "input_image_paths": input_paths,
"num_input_images": len(input_paths), "num_input_images": len(input_paths),
}, },
) )
# 4. Return Response response_kwargs = _build_image_response_kwargs(
if (response_format or "b64_json").lower() == "b64_json": save_file_path_list,
response_kwargs = {"data": []} resp_format,
for path in save_file_path_list: prompt,
if path == save_file_path and b64_data is not None: request_id,
b64 = b64_data result,
else: b64_list=b64_list,
with open(path, "rb") as f: cloud_url=cloud_url,
b64 = base64.b64encode(f.read()).decode("utf-8") fallback_url=f"/v1/images/{request_id}/content",
response_kwargs["data"].append(
ImageResponseData(
b64_json=b64,
revised_prompt=prompt,
file_path=os.path.abspath(path),
)
)
if result.peak_memory_mb and result.peak_memory_mb > 0:
response_kwargs["peak_memory_mb"] = result.peak_memory_mb
else:
response_kwargs = {
"data": [
ImageResponseData(
url=cloud_url if cloud_url else f"/v1/images/{request_id}/content",
revised_prompt=prompt,
file_path=os.path.abspath(save_file_path),
)
],
}
response_kwargs = add_common_data_to_response(
response_kwargs, request_id=request_id, result=result
) )
return ImageResponse(**response_kwargs) return ImageResponse(**response_kwargs)
@@ -1,6 +1,5 @@
# Copied and adapted from: https://github.com/hao-ai-lab/FastVideo # Copied and adapted from: https://github.com/hao-ai-lab/FastVideo
import base64 import base64
import dataclasses
import os import os
import re import re
import time import time
@@ -9,74 +8,43 @@ from typing import Any, List, Optional, Union
import httpx import httpx
from fastapi import UploadFile from fastapi import UploadFile
from sglang.multimodal_gen.configs.sample.sampling_params import DataType from sglang.multimodal_gen.configs.sample.sampling_params import (
from sglang.multimodal_gen.runtime.entrypoints.utils import save_outputs DataType,
SamplingParams,
)
from sglang.multimodal_gen.runtime.entrypoints.utils import (
ListLorasReq,
MergeLoraWeightsReq,
SetLoraReq,
ShutdownReq,
UnmergeLoraWeightsReq,
format_lora_message,
save_outputs,
)
from sglang.multimodal_gen.runtime.pipelines_core.schedule_batch import OutputBatch from sglang.multimodal_gen.runtime.pipelines_core.schedule_batch import OutputBatch
from sglang.multimodal_gen.runtime.scheduler_client import AsyncSchedulerClient from sglang.multimodal_gen.runtime.scheduler_client import AsyncSchedulerClient
from sglang.multimodal_gen.runtime.server_args import get_global_server_args
from sglang.multimodal_gen.runtime.utils.logging_utils import ( from sglang.multimodal_gen.runtime.utils.logging_utils import (
init_logger, init_logger,
log_batch_completion, log_batch_completion,
log_generation_timer, log_generation_timer,
) )
# re-export LoRA protocol types for backward compatibility
__all__ = [
"SetLoraReq",
"MergeLoraWeightsReq",
"UnmergeLoraWeightsReq",
"ListLorasReq",
"ShutdownReq",
"format_lora_message",
]
logger = init_logger(__name__) logger = init_logger(__name__)
OUTPUT_QUALITY_MAPPER = {"maximum": 100, "high": 90, "medium": 55, "low": 35} OUTPUT_QUALITY_MAPPER = {"maximum": 100, "high": 90, "medium": 55, "low": 35}
DEFAULT_FPS = 24
DEFAULT_VIDEO_SECONDS = 4
@dataclasses.dataclass
class SetLoraReq:
lora_nickname: Union[str, List[str]]
lora_path: Optional[Union[str, List[Optional[str]]]] = None
target: Union[str, List[str]] = "all"
strength: Union[float, List[float]] = 1.0 # LoRA strength for merge, default 1.0
@dataclasses.dataclass
class MergeLoraWeightsReq:
target: str = "all" # "all", "transformer", "transformer_2", "critic"
strength: float = 1.0 # LoRA strength for merge, default 1.0
@dataclasses.dataclass
class UnmergeLoraWeightsReq:
target: str = "all" # "all", "transformer", "transformer_2", "critic"
@dataclasses.dataclass
class ListLorasReq:
# Empty payload; used only as a type marker for listing LoRA status
pass
@dataclasses.dataclass
class ShutdownReq:
pass
def format_lora_message(
lora_nickname: Union[str, List[str]],
target: Union[str, List[str]],
strength: Union[float, List[float]],
) -> tuple[str, str, str]:
"""Format success message for single or multiple LoRAs"""
if isinstance(lora_nickname, list):
nickname_str = ", ".join(lora_nickname)
target_str = ", ".join(target) if isinstance(target, list) else target
strength_str = (
", ".join(f"{s:.2f}" for s in strength)
if isinstance(strength, list)
else f"{strength:.2f}"
)
else:
nickname_str = lora_nickname
target_str = target if isinstance(target, str) else ", ".join(target)
strength_str = (
f"{strength:.2f}"
if isinstance(strength, (int, float))
else ", ".join(f"{s:.2f}" for s in strength)
)
return nickname_str, target_str, strength_str
def _parse_size(size: str) -> tuple[int, int] | tuple[None, None]: def _parse_size(size: str) -> tuple[int, int] | tuple[None, None]:
@@ -90,6 +58,62 @@ def _parse_size(size: str) -> tuple[int, int] | tuple[None, None]:
return None, None return None, None
def choose_output_image_ext(
output_format: Optional[str], background: Optional[str]
) -> str:
fmt = (output_format or "").lower()
if fmt in {"png", "webp", "jpeg", "jpg"}:
return "jpg" if fmt == "jpeg" else fmt
if (background or "auto").lower() == "transparent":
return "png"
return "jpg"
def build_sampling_params(request_id: str, **kwargs) -> SamplingParams:
"""Build SamplingParams from request parameters.
Handles size parsing, output_quality resolution, and None filtering before
delegating to SamplingParams.from_user_sampling_params_args. Callers pass
only the parameters they have; None values are stripped automatically so
that SamplingParams defaults apply.
"""
server_args = get_global_server_args()
# pop HTTP-layer params that aren't SamplingParams fields
output_quality = kwargs.pop("output_quality", None)
has_explicit_compression = kwargs.get("output_compression") is not None
# parse "WxH" size string if provided
size = kwargs.pop("size", None)
if size:
w, h = _parse_size(size)
if w is not None:
kwargs.setdefault("width", w)
kwargs.setdefault("height", h)
# filter out None values to let SamplingParams defaults apply
kwargs = {k: v for k, v in kwargs.items() if v is not None}
kwargs.setdefault("save_output", True)
sampling_params = SamplingParams.from_user_sampling_params_args(
model_path=server_args.model_path,
server_args=server_args,
request_id=request_id,
**kwargs,
)
# resolve output_quality → output_compression with the correct data_type.
# SamplingParams.__post_init__ may have resolved with the wrong data_type
# (default VIDEO) before _adjust() set the correct one.
if not has_explicit_compression and output_quality is not None:
resolved = adjust_output_quality(output_quality, sampling_params.data_type)
if resolved is not None:
sampling_params.output_compression = resolved
return sampling_params
async def save_image_to_path(image: Union[UploadFile, str], target_path: str) -> str: async def save_image_to_path(image: Union[UploadFile, str], target_path: str) -> str:
input_path = await _maybe_url_image(image, target_path) input_path = await _maybe_url_image(image, target_path)
if input_path is None: if input_path is None:
@@ -171,24 +195,23 @@ async def _save_url_image_to_path(image_url: str, target_path: str) -> str:
async def _save_base64_image_to_path(base64_data: str, target_path: str) -> str: async def _save_base64_image_to_path(base64_data: str, target_path: str) -> str:
"""Decode base64 image data and save to target path.""" """Decode base64 image data and save to target path."""
_B64_FMT_HINT = (
"Failed to decode base64 image. "
"Expected format: `data:[<media-type>];base64,<data>`"
)
# split `data:[<media-type>][;base64],<data>` to media-type base64 data # split `data:[<media-type>][;base64],<data>` to media-type base64 data
pattern = r"data:(.*?)(;base64)?,(.*)" pattern = r"data:(.*?)(;base64)?,(.*)"
match = re.match(pattern, base64_data) match = re.match(pattern, base64_data)
if not match: if not match:
raise ValueError( raise ValueError(_B64_FMT_HINT)
f"Failed to decoding base64 image, please make sure the url format `data:[<media-type>][;base64],<data>` "
)
media_type = match.group(1) media_type = match.group(1)
is_base64 = match.group(2) is_base64 = match.group(2)
if not is_base64: if not is_base64:
raise ValueError( raise ValueError(f"{_B64_FMT_HINT} (missing ;base64 marker)")
f"Failed to decoding base64 image, please make sure the url format `data:[<media-type>][;base64],<data>` "
)
data = match.group(3) data = match.group(3)
if not data: if not data:
raise ValueError( raise ValueError(f"{_B64_FMT_HINT} (empty data payload)")
f"Failed to decoding base64 image, please make sure the url format `data:[<media-type>][;base64],<data>` "
)
# get ext from url # get ext from url
if media_type.startswith("image/"): if media_type.startswith("image/"):
ext = media_type.split("/")[-1].lower() ext = media_type.split("/")[-1].lower()
@@ -212,7 +235,7 @@ async def _save_base64_image_to_path(base64_data: str, target_path: str) -> str:
async def process_generation_batch( async def process_generation_batch(
scheduler_client: AsyncSchedulerClient, scheduler_client: AsyncSchedulerClient,
batch, batch,
) -> tuple[str, OutputBatch]: ) -> tuple[list[str], OutputBatch]:
total_start_time = time.perf_counter() total_start_time = time.perf_counter()
with log_generation_timer(logger, batch.prompt): with log_generation_timer(logger, batch.prompt):
result = await scheduler_client.forward([batch]) result = await scheduler_client.forward([batch])
@@ -222,40 +245,21 @@ async def process_generation_batch(
raise RuntimeError( raise RuntimeError(
f"Model generation returned no output. Error from scheduler: {error_msg}" f"Model generation returned no output. Error from scheduler: {error_msg}"
) )
save_file_path_list = []
# If output_file_paths is provided, use it instead of output.
if result.output_file_paths: if result.output_file_paths:
save_file_path_list = result.output_file_paths save_file_path_list = result.output_file_paths
else: else:
audio_sample_rate = result.audio_sample_rate num_outputs = len(result.output)
if batch.data_type == DataType.VIDEO: save_file_path_list = save_outputs(
save_file_path_list = save_outputs( result.output,
result.output, batch.data_type,
batch.data_type, batch.fps,
batch.fps, batch.save_output,
batch.save_output, lambda idx: str(batch.output_file_path(num_outputs, idx)),
lambda _idx: str( audio=result.audio,
os.path.join(batch.output_path, batch.output_file_name) audio_sample_rate=result.audio_sample_rate,
), output_compression=batch.output_compression,
audio=result.audio, )
audio_sample_rate=audio_sample_rate,
output_compression=batch.output_compression,
)
else:
save_file_path_list = save_outputs(
result.output,
batch.data_type,
batch.fps,
batch.save_output,
lambda idx: str(
os.path.join(
batch.output_path,
f"sample_{idx}_" + batch.output_file_name,
)
),
audio_sample_rate=audio_sample_rate,
output_compression=batch.output_compression,
)
total_time = time.perf_counter() - total_start_time total_time = time.perf_counter() - total_start_time
log_batch_completion(logger, 1, total_time) log_batch_completion(logger, 1, total_time)
@@ -30,9 +30,10 @@ from sglang.multimodal_gen.runtime.entrypoints.openai.protocol import (
from sglang.multimodal_gen.runtime.entrypoints.openai.storage import cloud_storage from sglang.multimodal_gen.runtime.entrypoints.openai.storage import cloud_storage
from sglang.multimodal_gen.runtime.entrypoints.openai.stores import VIDEO_STORE from sglang.multimodal_gen.runtime.entrypoints.openai.stores import VIDEO_STORE
from sglang.multimodal_gen.runtime.entrypoints.openai.utils import ( from sglang.multimodal_gen.runtime.entrypoints.openai.utils import (
_parse_size, DEFAULT_FPS,
DEFAULT_VIDEO_SECONDS,
add_common_data_to_response, add_common_data_to_response,
adjust_output_quality, build_sampling_params,
merge_image_input_list, merge_image_input_list,
process_generation_batch, process_generation_batch,
save_image_to_path, save_image_to_path,
@@ -46,66 +47,33 @@ logger = init_logger(__name__)
router = APIRouter(prefix="/v1/videos", tags=["videos"]) router = APIRouter(prefix="/v1/videos", tags=["videos"])
# NOTE(mick): the sampling params needs to be further adjusted def _build_video_sampling_params(request_id: str, request: VideoGenerationsRequest):
# FIXME: duplicated with the one in `image_api.py` """Resolve video-specific defaults (fps, seconds → num_frames) then
def _build_sampling_params_from_request( delegate to the shared build_sampling_params."""
request_id: str, request: VideoGenerationsRequest seconds = request.seconds if request.seconds is not None else DEFAULT_VIDEO_SECONDS
) -> SamplingParams: fps = request.fps if request.fps is not None else DEFAULT_FPS
if request.size is None: num_frames = request.num_frames if request.num_frames is not None else fps * seconds
width, height = None, None
else: return build_sampling_params(
width, height = _parse_size(request.size) request_id,
seconds = request.seconds if request.seconds is not None else 4 prompt=request.prompt,
fps_default = 24 size=request.size,
fps = request.fps if request.fps is not None else fps_default num_frames=num_frames,
derived_num_frames = fps * seconds fps=fps,
num_frames = ( image_path=request.input_reference,
request.num_frames if request.num_frames is not None else derived_num_frames output_file_name=request_id,
seed=request.seed,
generator_device=request.generator_device,
num_inference_steps=request.num_inference_steps,
guidance_scale=request.guidance_scale,
guidance_scale_2=request.guidance_scale_2,
negative_prompt=request.negative_prompt,
enable_teacache=request.enable_teacache,
output_path=request.output_path,
output_compression=request.output_compression,
output_quality=request.output_quality,
) )
server_args = get_global_server_args()
sampling_kwargs = {
"request_id": request_id,
"prompt": request.prompt,
"num_frames": num_frames,
"fps": fps,
"width": width,
"height": height,
"image_path": request.input_reference,
"save_output": True,
"output_file_name": request_id,
"seed": request.seed,
"generator_device": request.generator_device,
}
if request.num_inference_steps is not None:
sampling_kwargs["num_inference_steps"] = request.num_inference_steps
if request.guidance_scale is not None:
sampling_kwargs["guidance_scale"] = request.guidance_scale
if request.guidance_scale_2 is not None:
sampling_kwargs["guidance_scale_2"] = request.guidance_scale_2
if request.negative_prompt is not None:
sampling_kwargs["negative_prompt"] = request.negative_prompt
if request.enable_teacache is not None:
sampling_kwargs["enable_teacache"] = request.enable_teacache
if request.output_path is not None:
sampling_kwargs["output_path"] = request.output_path
if request.output_compression is not None:
sampling_kwargs["output_compression"] = request.output_compression
sampling_params = SamplingParams.from_user_sampling_params_args(
model_path=server_args.model_path,
server_args=server_args,
**sampling_kwargs,
)
if request.num_inference_steps is not None:
sampling_params.num_inference_steps = request.num_inference_steps
if request.guidance_scale is not None:
sampling_params.guidance_scale = request.guidance_scale
if request.seed is not None:
sampling_params.seed = request.seed
return sampling_params
# extract metadata which http_server needs to know # extract metadata which http_server needs to know
def _video_job_from_sampling( def _video_job_from_sampling(
@@ -127,6 +95,21 @@ def _video_job_from_sampling(
} }
async def _save_first_input_image(image_sources, request_id: str) -> str | None:
"""Save the first input image from a list of sources and return its path."""
image_list = merge_image_input_list(image_sources)
if not image_list:
return None
image = image_list[0]
uploads_dir = os.path.join("inputs", "uploads")
os.makedirs(uploads_dir, exist_ok=True)
filename = image.filename if hasattr(image, "filename") else "url_image"
target_path = os.path.join(uploads_dir, f"{request_id}_{filename}")
return await save_image_to_path(image, target_path)
async def _dispatch_job_async(job_id: str, batch: Req) -> None: async def _dispatch_job_async(job_id: str, batch: Req) -> None:
from sglang.multimodal_gen.runtime.scheduler_client import async_scheduler_client from sglang.multimodal_gen.runtime.scheduler_client import async_scheduler_client
@@ -190,26 +173,18 @@ async def create_video(
if not prompt: if not prompt:
raise HTTPException(status_code=400, detail="prompt is required") raise HTTPException(status_code=400, detail="prompt is required")
# Validate image input based on model task type # Validate image input based on model task type
image_list = merge_image_input_list(input_reference, reference_url) image_sources = merge_image_input_list(input_reference, reference_url)
if task_type.requires_image_input() and not image_list: if task_type.requires_image_input() and not image_sources:
raise HTTPException( raise HTTPException(
status_code=400, status_code=400,
detail="input_reference or reference_url is required for image-to-video generation", detail="input_reference or reference_url is required for image-to-video generation",
) )
input_path = None try:
if image_list: input_path = await _save_first_input_image(image_sources, request_id)
# Save first input image for image-to-video generation except Exception as e:
image = image_list[0] raise HTTPException(
uploads_dir = os.path.join("outputs", "uploads") status_code=400, detail=f"Failed to process image source: {str(e)}"
os.makedirs(uploads_dir, exist_ok=True) )
filename = image.filename if hasattr(image, "filename") else "url_image"
input_path = os.path.join(uploads_dir, f"{request_id}_{filename}")
try:
input_path = await save_image_to_path(image, input_path)
except Exception as e:
raise HTTPException(
status_code=400, detail=f"Failed to process image source: {str(e)}"
)
# Parse extra_body JSON (if provided in multipart form) to get fps/num_frames overrides # Parse extra_body JSON (if provided in multipart form) to get fps/num_frames overrides
extra_from_form: Dict[str, Any] = {} extra_from_form: Dict[str, Any] = {}
@@ -268,17 +243,12 @@ async def create_video(
status_code=400, status_code=400,
detail="input_reference or reference_url is required for image-to-video generation", detail="input_reference or reference_url is required for image-to-video generation",
) )
# for not multipart/form-data type # for non-multipart/form-data type
if payload.get("reference_url"): if payload.get("reference_url"):
image_list = merge_image_input_list(payload.get("reference_url"))
# Save first input image
image = image_list[0]
uploads_dir = os.path.join("outputs", "uploads")
os.makedirs(uploads_dir, exist_ok=True)
filename = image.filename if hasattr(image, "filename") else "url_image"
input_path = os.path.join(uploads_dir, f"{request_id}_{filename}")
try: try:
input_path = await save_image_to_path(image, input_path) input_path = await _save_first_input_image(
payload.get("reference_url"), request_id
)
except Exception as e: except Exception as e:
raise HTTPException( raise HTTPException(
status_code=400, status_code=400,
@@ -291,7 +261,7 @@ async def create_video(
logger.debug(f"Server received from create_video endpoint: req={req}") logger.debug(f"Server received from create_video endpoint: req={req}")
sampling_params = _build_sampling_params_from_request(request_id, req) sampling_params = _build_video_sampling_params(request_id, req)
job = _video_job_from_sampling(request_id, req, sampling_params) job = _video_job_from_sampling(request_id, req, sampling_params)
await VIDEO_STORE.upsert(request_id, job) await VIDEO_STORE.upsert(request_id, job)
@@ -300,10 +270,6 @@ async def create_video(
server_args=server_args, server_args=server_args,
sampling_params=sampling_params, sampling_params=sampling_params,
) )
if batch.output_compression is None:
batch.output_compression = adjust_output_quality(
req.output_quality, batch.data_type
)
# Add diffusers_kwargs if provided # Add diffusers_kwargs if provided
if req.diffusers_kwargs: if req.diffusers_kwargs:
batch.extra["diffusers_kwargs"] = req.diffusers_kwargs batch.extra["diffusers_kwargs"] = req.diffusers_kwargs
@@ -12,7 +12,8 @@ import os
import shutil import shutil
import subprocess import subprocess
import tempfile import tempfile
from typing import Any, Callable, Optional, Sequence from dataclasses import dataclass, field
from typing import Any, Callable, List, Optional, Sequence, Union
import imageio import imageio
import numpy as np import numpy as np
@@ -39,6 +40,79 @@ from sglang.multimodal_gen.runtime.utils.logging_utils import CYAN, RESET, init_
logger = init_logger(__name__) logger = init_logger(__name__)
@dataclass
class SetLoraReq:
lora_nickname: Union[str, List[str]]
lora_path: Optional[Union[str, List[Optional[str]]]] = None
target: Union[str, List[str]] = "all"
strength: Union[float, List[float]] = 1.0
@dataclass
class MergeLoraWeightsReq:
target: str = "all"
strength: float = 1.0
@dataclass
class UnmergeLoraWeightsReq:
target: str = "all"
@dataclass
class ListLorasReq:
pass
@dataclass
class ShutdownReq:
pass
def format_lora_message(
lora_nickname: Union[str, List[str]],
target: Union[str, List[str]],
strength: Union[float, List[float]],
) -> tuple[str, str, str]:
"""Format success message for single or multiple LoRAs."""
if isinstance(lora_nickname, list):
nickname_str = ", ".join(lora_nickname)
target_str = ", ".join(target) if isinstance(target, list) else target
strength_str = (
", ".join(f"{s:.2f}" for s in strength)
if isinstance(strength, list)
else f"{strength:.2f}"
)
else:
nickname_str = lora_nickname
target_str = target if isinstance(target, str) else ", ".join(target)
strength_str = (
f"{strength:.2f}"
if isinstance(strength, (int, float))
else ", ".join(f"{s:.2f}" for s in strength)
)
return nickname_str, target_str, strength_str
@dataclass
class GenerationResult:
"""Result of a single generation request from DiffGenerator."""
samples: Any = None
frames: Any = None
audio: Any = None
prompt: str | None = None
size: tuple | None = None # (height, width, num_frames)
generation_time: float = 0.0
peak_memory_mb: float = 0.0
timings: dict = field(default_factory=dict)
trajectory_latents: Any = None
trajectory_timesteps: Any = None
trajectory_decoded: Any = None
prompt_index: int = 0
output_file_path: str | None = None
def _normalize_audio_to_numpy(audio: Any) -> np.ndarray | None: def _normalize_audio_to_numpy(audio: Any) -> np.ndarray | None:
"""Convert audio (torch / numpy) into a float32 numpy array in [-1, 1], best-effort.""" """Convert audio (torch / numpy) into a float32 numpy array in [-1, 1], best-effort."""
if audio is None: if audio is None:
@@ -12,13 +12,15 @@ import zmq
from sglang.multimodal_gen.configs.pipeline_configs.base import ModelTaskType from sglang.multimodal_gen.configs.pipeline_configs.base import ModelTaskType
from sglang.multimodal_gen.runtime.entrypoints.openai.utils import ( from sglang.multimodal_gen.runtime.entrypoints.openai.utils import (
_parse_size,
save_image_to_path,
)
from sglang.multimodal_gen.runtime.entrypoints.utils import (
ListLorasReq, ListLorasReq,
MergeLoraWeightsReq, MergeLoraWeightsReq,
SetLoraReq, SetLoraReq,
ShutdownReq, ShutdownReq,
UnmergeLoraWeightsReq, UnmergeLoraWeightsReq,
_parse_size,
save_image_to_path,
) )
from sglang.multimodal_gen.runtime.managers.gpu_worker import GPUWorker from sglang.multimodal_gen.runtime.managers.gpu_worker import GPUWorker
from sglang.multimodal_gen.runtime.pipelines_core import Req from sglang.multimodal_gen.runtime.pipelines_core import Req
@@ -159,7 +159,7 @@ class InputValidationStage(PipelineStage):
scale = max(ow / iw, oh / ih) scale = max(ow / iw, oh / ih)
img = img.resize((round(iw * scale), round(ih * scale)), Image.LANCZOS) img = img.resize((round(iw * scale), round(ih * scale)), Image.LANCZOS)
logger.debug("resized img height: %s, img width: %s", img.height, img.width) logger.debug("resized condition image to: %sx%s", img.height, img.width)
# center-crop # center-crop
x1 = (img.width - ow) // 2 x1 = (img.width - ow) // 2
@@ -131,7 +131,8 @@ class TimestepPreparationStage(PipelineStage):
# Update batch with prepared timesteps # Update batch with prepared timesteps
batch.timesteps = timesteps batch.timesteps = timesteps
self.log_debug("timesteps: %s", timesteps) if not batch.is_warmup:
self.log_debug("timesteps: %s", timesteps)
return batch return batch
def verify_input(self, batch: Req, server_args: ServerArgs) -> VerificationResult: def verify_input(self, batch: Req, server_args: ServerArgs) -> VerificationResult: