94057c3d3e
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
756 lines
23 KiB
Python
756 lines
23 KiB
Python
# Copied and adapted from: https://github.com/hao-ai-lab/FastVideo
|
|
|
|
# SPDX-License-Identifier: Apache-2.0
|
|
"""
|
|
DiffGenerator module for sglang-diffusion.
|
|
|
|
This module provides a consolidated interface for generating videos using
|
|
diffusion models.
|
|
"""
|
|
|
|
import json
|
|
import os
|
|
import shutil
|
|
import subprocess
|
|
import tempfile
|
|
from copy import copy
|
|
from dataclasses import dataclass, field
|
|
from typing import Any, Callable, List, Optional, Sequence, Union
|
|
|
|
import imageio
|
|
import numpy as np
|
|
import torch
|
|
from PIL import Image
|
|
|
|
try:
|
|
import scipy.io.wavfile as scipy_wavfile
|
|
except ImportError: # pragma: no cover
|
|
scipy_wavfile = None
|
|
|
|
try:
|
|
import imageio_ffmpeg as _imageio_ffmpeg
|
|
except ImportError: # pragma: no cover
|
|
_imageio_ffmpeg = None
|
|
|
|
from sglang.multimodal_gen.configs.sample.sampling_params import (
|
|
DataType,
|
|
SamplingParams,
|
|
)
|
|
from sglang.multimodal_gen.runtime.pipelines_core.schedule_batch import Req
|
|
from sglang.multimodal_gen.runtime.server_args import ServerArgs
|
|
from sglang.multimodal_gen.runtime.utils.logging_utils import CYAN, RESET, init_logger
|
|
from sglang.srt.observability.trace import TraceReqContext
|
|
|
|
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
|
|
merge_mode: Optional[str] = None
|
|
|
|
|
|
@dataclass
|
|
class MergeLoraWeightsReq:
|
|
target: str = "all"
|
|
strength: float = 1.0
|
|
|
|
|
|
@dataclass
|
|
class UnmergeLoraWeightsReq:
|
|
target: str = "all"
|
|
|
|
|
|
@dataclass
|
|
class ListLorasReq:
|
|
pass
|
|
|
|
|
|
@dataclass
|
|
class ShutdownReq:
|
|
pass
|
|
|
|
|
|
@dataclass
|
|
class ReleaseRealtimeSessionReq:
|
|
session_id: str
|
|
|
|
|
|
@dataclass
|
|
class GetDisaggStatsReq:
|
|
"""Request to get disagg pipeline metrics from the scheduler."""
|
|
|
|
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
|
|
action: Any = None # [T, raw_action_dim] predicted action (policy/inverse_dynamics)
|
|
prompt: str | None = None
|
|
size: tuple | None = None # (height, width, num_frames)
|
|
generation_time: float = 0.0
|
|
peak_memory_mb: float = 0.0
|
|
metrics: dict = field(default_factory=dict)
|
|
trajectory_latents: Any = None
|
|
trajectory_timesteps: Any = None
|
|
rollout_trajectory_data: Any = None
|
|
trajectory_decoded: Any = None
|
|
prompt_index: int = 0
|
|
output_file_path: str | None = None
|
|
|
|
|
|
@dataclass
|
|
class MaterializedOutput:
|
|
sample: Any
|
|
frames: list[Any]
|
|
audio: Any = None
|
|
fps: int = 0
|
|
|
|
|
|
def normalize_output_seeds(
|
|
seed: int | list[int],
|
|
*,
|
|
num_outputs_per_prompt: int,
|
|
num_prompts: int = 1,
|
|
prompt_index: int = 0,
|
|
) -> list[int]:
|
|
"""
|
|
return a list of seed with size equal to `num_outputs_per_prompt`
|
|
"""
|
|
if num_outputs_per_prompt <= 0:
|
|
raise ValueError(
|
|
f"num_outputs_per_prompt must be positive, got {num_outputs_per_prompt}"
|
|
)
|
|
|
|
if isinstance(seed, list):
|
|
seeds = [int(item) for item in seed]
|
|
total_outputs = num_outputs_per_prompt * num_prompts
|
|
if len(seeds) == num_outputs_per_prompt:
|
|
return seeds
|
|
if len(seeds) == total_outputs:
|
|
start = prompt_index * num_outputs_per_prompt
|
|
return seeds[start : start + num_outputs_per_prompt]
|
|
raise ValueError(
|
|
"seed list length must match num_outputs_per_prompt "
|
|
f"({num_outputs_per_prompt}) or total outputs ({total_outputs}), "
|
|
f"got {len(seeds)}"
|
|
)
|
|
|
|
base_seed = int(seed)
|
|
return [base_seed + i for i in range(num_outputs_per_prompt)]
|
|
|
|
|
|
def _with_output_index_suffix(output_file_name: str, output_index: int) -> str:
|
|
base, ext = os.path.splitext(output_file_name)
|
|
return f"{base}_{output_index}{ext}"
|
|
|
|
|
|
def _copy_trace_ctx_for_output(req: Req, request_id: str | None, output_index: int):
|
|
trace_ctx = req.trace_ctx
|
|
if output_index == 0 or not trace_ctx.tracing_enable:
|
|
return trace_ctx
|
|
|
|
output_trace_ctx = TraceReqContext(
|
|
rid=request_id,
|
|
module_name=trace_ctx.module_name,
|
|
external_trace_header=trace_ctx.external_trace_header,
|
|
)
|
|
output_trace_ctx.trace_req_start()
|
|
return output_trace_ctx
|
|
|
|
|
|
def _copy_req_for_output(
|
|
req: Req,
|
|
*,
|
|
request_id: str | None,
|
|
output_index: int,
|
|
) -> Req:
|
|
"""Create a lightweight per-output ``Req`` without deep-copying tensors."""
|
|
output_req = copy(req)
|
|
output_req.sampling_params = copy(req.sampling_params)
|
|
output_req.extra = dict(req.extra)
|
|
output_req.condition_inputs = dict(req.condition_inputs)
|
|
output_req.trace_ctx = _copy_trace_ctx_for_output(req, request_id, output_index)
|
|
return output_req
|
|
|
|
|
|
def expand_request_outputs(
|
|
req: Req,
|
|
*,
|
|
num_prompts: int = 1,
|
|
prompt_index: int = 0,
|
|
) -> list[Req]:
|
|
"""
|
|
Expand a req to a list with size equal to `num_prompts`
|
|
"""
|
|
num_outputs = int(req.num_outputs_per_prompt)
|
|
# each req must has different seed
|
|
seeds = normalize_output_seeds(
|
|
req.seed,
|
|
num_outputs_per_prompt=num_outputs,
|
|
num_prompts=num_prompts,
|
|
prompt_index=prompt_index,
|
|
)
|
|
|
|
if num_outputs == 1:
|
|
req.seed = seeds[0]
|
|
req.seeds = None
|
|
req.generator = None
|
|
return [req]
|
|
|
|
expanded: list[Req] = []
|
|
for output_index, seed in enumerate(seeds):
|
|
output_request_id = (
|
|
f"{req.request_id}:{output_index}" if req.request_id is not None else None
|
|
)
|
|
output_req = _copy_req_for_output(
|
|
req, request_id=output_request_id, output_index=output_index
|
|
)
|
|
output_req.seed = seed
|
|
output_req.num_outputs_per_prompt = 1
|
|
output_req.seeds = None
|
|
output_req.generator = None
|
|
output_req.extra["parent_request_id"] = req.request_id
|
|
output_req.extra["output_index"] = output_index
|
|
|
|
if output_request_id is not None:
|
|
output_req.request_id = output_request_id
|
|
|
|
if req.output_file_name:
|
|
output_req.output_file_name = _with_output_index_suffix(
|
|
req.output_file_name, output_index
|
|
)
|
|
output_req.validate()
|
|
expanded.append(output_req)
|
|
|
|
return expanded
|
|
|
|
|
|
def _normalize_audio_to_numpy(audio: Any) -> np.ndarray | None:
|
|
"""Convert audio (torch / numpy) into a float32 numpy array in [-1, 1], best-effort."""
|
|
if audio is None:
|
|
return None
|
|
if isinstance(audio, torch.Tensor):
|
|
audio_np = audio.detach().float().clamp(-1.0, 1.0).cpu().numpy()
|
|
elif isinstance(audio, np.ndarray):
|
|
audio_np = audio.astype(np.float32, copy=False)
|
|
audio_np = np.clip(audio_np, -1.0, 1.0)
|
|
else:
|
|
return None
|
|
|
|
# 1. Squeeze leading singleton dimensions (Batch, etc.)
|
|
while audio_np.ndim > 1 and audio_np.shape[0] == 1:
|
|
audio_np = audio_np.squeeze(0)
|
|
|
|
# 2. Handle (C, L) -> (L, C)
|
|
if audio_np.ndim == 2 and audio_np.shape[0] < audio_np.shape[1]:
|
|
audio_np = audio_np.transpose(1, 0)
|
|
|
|
# 3. Final safety check: if still 2D and channels (dim 1) is huge, something is wrong
|
|
if audio_np.ndim == 2 and audio_np.shape[1] > 256 and audio_np.shape[0] == 1:
|
|
audio_np = audio_np.flatten()
|
|
|
|
return audio_np
|
|
|
|
|
|
def _pick_audio_sample_rate(
|
|
*,
|
|
audio_np: np.ndarray,
|
|
audio_sample_rate: Optional[int],
|
|
fps: int,
|
|
num_frames: int,
|
|
) -> int:
|
|
"""Pick a plausible sample rate, falling back to inferring from video duration."""
|
|
selected_sr = int(audio_sample_rate) if audio_sample_rate is not None else None
|
|
if selected_sr is None or not (8000 <= selected_sr <= 192000):
|
|
selected_sr = 24000
|
|
try:
|
|
duration_s = float(num_frames) / float(fps) if fps else 0.0
|
|
if duration_s > 0:
|
|
audio_len = (
|
|
int(audio_np.shape[0])
|
|
if audio_np.ndim == 2
|
|
else int(audio_np.shape[-1])
|
|
)
|
|
inferred_sr = int(round(float(audio_len) / duration_s))
|
|
if 8000 <= inferred_sr <= 192000:
|
|
selected_sr = inferred_sr
|
|
except Exception:
|
|
pass
|
|
return selected_sr
|
|
|
|
|
|
def _resolve_ffmpeg_exe() -> str:
|
|
ffmpeg_exe = "ffmpeg"
|
|
ffmpeg_on_path = shutil.which("ffmpeg")
|
|
if ffmpeg_on_path:
|
|
ffmpeg_exe = ffmpeg_on_path
|
|
try:
|
|
if _imageio_ffmpeg is not None:
|
|
ffmpeg_exe = _imageio_ffmpeg.get_ffmpeg_exe()
|
|
except Exception:
|
|
pass
|
|
|
|
ffmpeg_ok = False
|
|
if ffmpeg_exe:
|
|
if os.path.isabs(ffmpeg_exe):
|
|
ffmpeg_ok = os.path.exists(ffmpeg_exe)
|
|
else:
|
|
ffmpeg_ok = shutil.which(ffmpeg_exe) is not None
|
|
if not ffmpeg_ok:
|
|
raise RuntimeError("ffmpeg not found")
|
|
return ffmpeg_exe
|
|
|
|
|
|
def _mux_audio_np_into_mp4(
|
|
*,
|
|
save_file_path: str,
|
|
audio_np: np.ndarray,
|
|
sample_rate: int,
|
|
ffmpeg_exe: str,
|
|
) -> None:
|
|
merged_path = save_file_path.rsplit(".", 1)[0] + ".tmp_mux.mp4"
|
|
tmp_wav_path = None
|
|
try:
|
|
if scipy_wavfile is None:
|
|
raise RuntimeError(
|
|
"scipy is required to mux audio into mp4 (pip install scipy)"
|
|
)
|
|
with tempfile.NamedTemporaryFile(suffix=".wav", delete=False) as f:
|
|
tmp_wav_path = f.name
|
|
scipy_wavfile.write(tmp_wav_path, sample_rate, audio_np)
|
|
subprocess.run(
|
|
[
|
|
ffmpeg_exe,
|
|
"-y",
|
|
"-i",
|
|
save_file_path,
|
|
"-i",
|
|
tmp_wav_path,
|
|
"-c:v",
|
|
"copy",
|
|
"-c:a",
|
|
"aac",
|
|
"-strict",
|
|
"experimental",
|
|
merged_path,
|
|
],
|
|
check=True,
|
|
stdout=subprocess.DEVNULL,
|
|
stderr=subprocess.DEVNULL,
|
|
)
|
|
os.replace(merged_path, save_file_path)
|
|
finally:
|
|
if tmp_wav_path:
|
|
try:
|
|
os.remove(tmp_wav_path)
|
|
except OSError:
|
|
pass
|
|
if os.path.exists(merged_path):
|
|
try:
|
|
os.remove(merged_path)
|
|
except OSError:
|
|
pass
|
|
|
|
|
|
def _maybe_mux_audio_into_mp4(
|
|
*,
|
|
save_file_path: str,
|
|
audio: Any,
|
|
frames: list,
|
|
fps: int,
|
|
audio_sample_rate: Optional[int],
|
|
) -> None:
|
|
"""Best-effort mux audio into an already-written mp4 at save_file_path.
|
|
|
|
Any failure should keep the silent video and only log a warning.
|
|
"""
|
|
audio_np = _normalize_audio_to_numpy(audio)
|
|
if audio_np is None:
|
|
return
|
|
selected_sr = _pick_audio_sample_rate(
|
|
audio_np=audio_np,
|
|
audio_sample_rate=audio_sample_rate,
|
|
fps=fps,
|
|
num_frames=len(frames),
|
|
)
|
|
|
|
try:
|
|
ffmpeg_exe = _resolve_ffmpeg_exe()
|
|
_mux_audio_np_into_mp4(
|
|
save_file_path=save_file_path,
|
|
audio_np=audio_np,
|
|
sample_rate=selected_sr,
|
|
ffmpeg_exe=ffmpeg_exe,
|
|
)
|
|
except Exception as e:
|
|
logger.warning(
|
|
"Failed to mux audio into mp4 (saved silent video): %s",
|
|
str(e),
|
|
)
|
|
|
|
|
|
def prepare_request(
|
|
server_args: ServerArgs,
|
|
sampling_params: SamplingParams,
|
|
external_trace_header: dict[str, str] | None = None,
|
|
) -> Req:
|
|
"""
|
|
Create a Req object with sampling_params as a parameter.
|
|
"""
|
|
req = Req(
|
|
sampling_params=sampling_params,
|
|
VSA_sparsity=server_args.attention_backend_config.VSA_sparsity,
|
|
)
|
|
sampling_params.apply_request_extra(req)
|
|
if getattr(sampling_params, "max_sequence_length", None) is not None:
|
|
req.max_sequence_length = sampling_params.max_sequence_length
|
|
|
|
diffusers_kwargs = getattr(sampling_params, "diffusers_kwargs", None)
|
|
if diffusers_kwargs and "max_sequence_length" in diffusers_kwargs:
|
|
req.max_sequence_length = diffusers_kwargs["max_sequence_length"]
|
|
|
|
if not isinstance(req.prompt, str):
|
|
raise TypeError(f"`prompt` must be a string, but got {type(req.prompt)}")
|
|
|
|
req_width = getattr(req, "width", None)
|
|
req_height = getattr(req, "height", None)
|
|
if (req_width is not None and req_width <= 0) or (
|
|
req_height is not None and req_height <= 0
|
|
):
|
|
raise ValueError(
|
|
f"Height and width must be positive, got height={req_height}, width={req_width}"
|
|
)
|
|
|
|
if server_args.enable_trace:
|
|
trace_ctx = TraceReqContext(
|
|
rid=sampling_params.request_id,
|
|
module_name="diffusion",
|
|
external_trace_header=external_trace_header,
|
|
)
|
|
trace_ctx.trace_req_start()
|
|
req.trace_ctx = trace_ctx
|
|
|
|
return req
|
|
|
|
|
|
def attach_audio_to_video_sample(
|
|
sample: Any,
|
|
audio: Any,
|
|
output_idx: int,
|
|
) -> Any:
|
|
"""Attach per-sample audio for video outputs when available."""
|
|
audio = select_output_audio(audio, output_idx)
|
|
if audio is None:
|
|
return sample
|
|
if not (isinstance(sample, (tuple, list)) and len(sample) == 2):
|
|
return (sample, audio)
|
|
return sample
|
|
|
|
|
|
def select_output_audio(audio: Any, output_idx: int) -> Any:
|
|
if isinstance(audio, torch.Tensor) and audio.ndim >= 2:
|
|
return audio[output_idx] if audio.shape[0] > output_idx else None
|
|
if isinstance(audio, np.ndarray) and audio.ndim >= 2:
|
|
return audio[output_idx] if audio.shape[0] > output_idx else None
|
|
return audio
|
|
|
|
|
|
def _split_sample_audio(sample: Any) -> tuple[Any, Any]:
|
|
if isinstance(sample, (tuple, list)) and len(sample) == 2:
|
|
return sample[0], sample[1]
|
|
return sample, None
|
|
|
|
|
|
def _sample_to_uint8_frames(sample: Any) -> list[Any]:
|
|
"""return numpy frames in THCW format"""
|
|
if isinstance(sample, torch.Tensor):
|
|
# sample is raw tensor
|
|
if sample.dim() == 3:
|
|
sample = sample.unsqueeze(1)
|
|
sample = (sample * 255).clamp(0, 255).to(torch.uint8)
|
|
videos = sample.permute(1, 2, 3, 0).contiguous().cpu().numpy()
|
|
return list(videos)
|
|
|
|
if not isinstance(sample, np.ndarray):
|
|
raise TypeError(f"Unsupported sample type: {type(sample)}")
|
|
|
|
# sample is numpy frames
|
|
arr = sample
|
|
if arr.ndim == 3:
|
|
if arr.shape[-1] in (1, 3, 4):
|
|
arr = arr[None, ...]
|
|
else:
|
|
arr = arr[..., None]
|
|
if arr.ndim != 4:
|
|
raise ValueError(f"Unexpected numpy sample shape: {tuple(arr.shape)}")
|
|
|
|
if arr.shape[-1] not in (1, 3, 4) and arr.shape[0] in (1, 3, 4):
|
|
t = torch.from_numpy(arr)
|
|
if t.dim() == 3:
|
|
t = t.unsqueeze(1)
|
|
t = (t * 255).clamp(0, 255).to(torch.uint8)
|
|
videos = t.permute(1, 2, 3, 0).contiguous().cpu().numpy()
|
|
return list(videos)
|
|
|
|
if arr.dtype != np.uint8:
|
|
arr = (np.clip(arr, 0.0, 1.0) * 255.0).astype(np.uint8)
|
|
return list(arr)
|
|
|
|
|
|
def materialize_output_sample(
|
|
sample: Any,
|
|
data_type: DataType,
|
|
fps: int,
|
|
*,
|
|
enable_frame_interpolation: bool = False,
|
|
frame_interpolation_exp: int = 1,
|
|
frame_interpolation_scale: float = 1.0,
|
|
frame_interpolation_model_path: Optional[str] = None,
|
|
enable_upscaling: bool = False,
|
|
upscaling_model_path: Optional[str] = None,
|
|
upscaling_scale: int = 4,
|
|
) -> MaterializedOutput:
|
|
"""materialize samples, apply postprocessing if applicable"""
|
|
sample_without_audio, audio = _split_sample_audio(sample)
|
|
frames = _sample_to_uint8_frames(sample_without_audio)
|
|
|
|
# frames are uint8 numpy arrays in THWC format at this point
|
|
if enable_frame_interpolation and data_type == DataType.VIDEO and len(frames) > 1:
|
|
from sglang.multimodal_gen.runtime.postprocess import (
|
|
interpolate_video_frames,
|
|
)
|
|
|
|
frames, multiplier = interpolate_video_frames(
|
|
frames,
|
|
exp=frame_interpolation_exp,
|
|
scale=frame_interpolation_scale,
|
|
model_path=frame_interpolation_model_path,
|
|
)
|
|
fps = fps * multiplier
|
|
|
|
if enable_upscaling and frames:
|
|
from sglang.multimodal_gen.runtime.postprocess import upscale_frames
|
|
|
|
frames = upscale_frames(
|
|
frames,
|
|
model_path=upscaling_model_path,
|
|
scale=upscaling_scale,
|
|
)
|
|
|
|
return MaterializedOutput(sample=sample, frames=frames, audio=audio, fps=fps)
|
|
|
|
|
|
def save_materialized_output(
|
|
materialized: MaterializedOutput,
|
|
data_type: DataType,
|
|
save_file_path: Optional[str],
|
|
*,
|
|
save_output: bool = True,
|
|
audio_sample_rate: Optional[int] = None,
|
|
output_compression: Optional[int] = None,
|
|
) -> None:
|
|
if not save_output:
|
|
return
|
|
if not save_file_path:
|
|
logger.info("No output path provided, output not saved")
|
|
return
|
|
|
|
os.makedirs(os.path.dirname(save_file_path), exist_ok=True)
|
|
if data_type == DataType.VIDEO:
|
|
quality = output_compression / 10 if output_compression is not None else 5
|
|
imageio.mimsave(
|
|
save_file_path,
|
|
materialized.frames,
|
|
fps=materialized.fps,
|
|
format=data_type.get_default_extension(),
|
|
codec="libx264",
|
|
quality=quality,
|
|
)
|
|
|
|
_maybe_mux_audio_into_mp4(
|
|
save_file_path=save_file_path,
|
|
audio=materialized.audio,
|
|
frames=materialized.frames,
|
|
fps=materialized.fps,
|
|
audio_sample_rate=audio_sample_rate,
|
|
)
|
|
else:
|
|
quality = output_compression if output_compression is not None else 75
|
|
if len(materialized.frames) > 1:
|
|
for i, image in enumerate(materialized.frames):
|
|
parts = save_file_path.rsplit(".", 1)
|
|
if len(parts) == 2:
|
|
indexed_path = f"{parts[0]}_{i}.{parts[1]}"
|
|
else:
|
|
indexed_path = f"{save_file_path}_{i}"
|
|
_save_image_frame(indexed_path, image, quality, output_compression)
|
|
else:
|
|
_save_image_frame(
|
|
save_file_path, materialized.frames[0], quality, output_compression
|
|
)
|
|
logger.info(f"Output saved to {CYAN}{save_file_path}{RESET}")
|
|
|
|
|
|
def _save_image_frame(
|
|
path: str, frame: np.ndarray, quality: int | None, output_compression: int | None
|
|
) -> None:
|
|
ext = os.path.splitext(path)[1].lower()
|
|
if ext == ".png":
|
|
compress_level = 1
|
|
if output_compression is not None and output_compression != 75:
|
|
compress_level = max(0, min(9, round(output_compression / 100 * 9)))
|
|
if frame.ndim == 3 and frame.shape[-1] == 1:
|
|
frame = frame[..., 0]
|
|
Image.fromarray(frame).save(path, format="PNG", compress_level=compress_level)
|
|
else:
|
|
imageio.imwrite(path, frame, quality=quality)
|
|
|
|
|
|
def save_outputs(
|
|
outputs: Sequence[Any],
|
|
data_type: DataType,
|
|
fps: int,
|
|
save_output: bool,
|
|
build_output_path: Callable[[int], str],
|
|
*,
|
|
audio: Any = None,
|
|
audio_sample_rate: Optional[int] = None,
|
|
samples_out: Optional[list[Any]] = None,
|
|
audios_out: Optional[list[Any]] = None,
|
|
frames_out: Optional[list[Any]] = None,
|
|
output_compression: Optional[int] = None,
|
|
enable_frame_interpolation: bool = False,
|
|
frame_interpolation_exp: int = 1,
|
|
frame_interpolation_scale: float = 1.0,
|
|
frame_interpolation_model_path: Optional[str] = None,
|
|
enable_upscaling: bool = False,
|
|
upscaling_model_path: Optional[str] = None,
|
|
upscaling_scale: int = 4,
|
|
) -> list[str]:
|
|
output_paths: list[str] = []
|
|
for idx, sample in enumerate(outputs):
|
|
save_file_path = build_output_path(idx)
|
|
if data_type == DataType.ACTION:
|
|
if samples_out is not None:
|
|
samples_out.append(sample)
|
|
if audios_out is not None:
|
|
audios_out.append(None)
|
|
if frames_out is not None:
|
|
frames_out.append([])
|
|
if save_output and save_file_path:
|
|
os.makedirs(os.path.dirname(save_file_path) or ".", exist_ok=True)
|
|
with open(save_file_path, "w", encoding="utf-8") as f:
|
|
json.dump(sample, f, ensure_ascii=False)
|
|
logger.info(f"Output saved to {CYAN}{save_file_path}{RESET}")
|
|
output_paths.append(save_file_path)
|
|
continue
|
|
|
|
if data_type == DataType.VIDEO:
|
|
sample = attach_audio_to_video_sample(sample, audio, idx)
|
|
|
|
frames = post_process_sample(
|
|
sample,
|
|
data_type,
|
|
fps,
|
|
save_output,
|
|
save_file_path,
|
|
audio_sample_rate=audio_sample_rate,
|
|
output_compression=output_compression,
|
|
enable_frame_interpolation=enable_frame_interpolation,
|
|
frame_interpolation_exp=frame_interpolation_exp,
|
|
frame_interpolation_scale=frame_interpolation_scale,
|
|
frame_interpolation_model_path=frame_interpolation_model_path,
|
|
enable_upscaling=enable_upscaling,
|
|
upscaling_model_path=upscaling_model_path,
|
|
upscaling_scale=upscaling_scale,
|
|
)
|
|
|
|
if samples_out is not None:
|
|
samples_out.append(sample)
|
|
if audios_out is not None:
|
|
if data_type == DataType.VIDEO:
|
|
audios_out.append(select_output_audio(audio, idx))
|
|
else:
|
|
audios_out.append(audio)
|
|
if frames_out is not None:
|
|
frames_out.append(frames)
|
|
output_paths.append(save_file_path)
|
|
return output_paths
|
|
|
|
|
|
def post_process_sample(
|
|
sample: Any,
|
|
data_type: DataType,
|
|
fps: int,
|
|
save_output: bool = True,
|
|
save_file_path: Optional[str] = None,
|
|
audio_sample_rate: Optional[int] = None,
|
|
output_compression: Optional[int] = None,
|
|
enable_frame_interpolation: bool = False,
|
|
frame_interpolation_exp: int = 1,
|
|
frame_interpolation_scale: float = 1.0,
|
|
frame_interpolation_model_path: Optional[str] = None,
|
|
enable_upscaling: bool = False,
|
|
upscaling_model_path: Optional[str] = None,
|
|
upscaling_scale: int = 4,
|
|
) -> list[Any]:
|
|
"""materialize frames and save outputs (optional)"""
|
|
if data_type == DataType.ACTION:
|
|
return []
|
|
|
|
materialized = materialize_output_sample(
|
|
sample,
|
|
data_type,
|
|
fps,
|
|
enable_frame_interpolation=enable_frame_interpolation,
|
|
frame_interpolation_exp=frame_interpolation_exp,
|
|
frame_interpolation_scale=frame_interpolation_scale,
|
|
frame_interpolation_model_path=frame_interpolation_model_path,
|
|
enable_upscaling=enable_upscaling,
|
|
upscaling_model_path=upscaling_model_path,
|
|
upscaling_scale=upscaling_scale,
|
|
)
|
|
save_materialized_output(
|
|
materialized,
|
|
data_type,
|
|
save_file_path,
|
|
save_output=save_output,
|
|
audio_sample_rate=audio_sample_rate,
|
|
output_compression=output_compression,
|
|
)
|
|
return materialized.frames
|