[diffusion] fix: make input/output file save paths configurable and disableable (#19580)
Co-authored-by: Mick <mickjagger19@icloud.com>
This commit is contained in:
@@ -248,6 +248,7 @@ lmms-eval
|
|||||||
**/.serena/
|
**/.serena/
|
||||||
ctags/
|
ctags/
|
||||||
outputs/
|
outputs/
|
||||||
|
inputs/
|
||||||
|
|
||||||
# Eval Cache
|
# Eval Cache
|
||||||
.longbench_cache/
|
.longbench_cache/
|
||||||
@@ -272,6 +273,7 @@ sgl-kernel/csrc/**/*_musa/
|
|||||||
*.mudmp
|
*.mudmp
|
||||||
|
|
||||||
# Others
|
# Others
|
||||||
|
# diffusion 3D outputs
|
||||||
*.glb
|
*.glb
|
||||||
*.ply
|
*.ply
|
||||||
*.npz
|
*.npz
|
||||||
|
|||||||
@@ -539,7 +539,7 @@ class PipelineConfig:
|
|||||||
cls, kwargs: dict[str, Any], config_cli_prefix: str = ""
|
cls, kwargs: dict[str, Any], config_cli_prefix: str = ""
|
||||||
) -> "PipelineConfig":
|
) -> "PipelineConfig":
|
||||||
"""
|
"""
|
||||||
Load PipelineConfig from kwargs Dictionary.
|
Load PipelineConfig from kwargs Dictionary, as part of the ServerArg initialization process
|
||||||
kwargs: dictionary of kwargs
|
kwargs: dictionary of kwargs
|
||||||
config_cli_prefix: prefix of CLI arguments for this PipelineConfig instance
|
config_cli_prefix: prefix of CLI arguments for this PipelineConfig instance
|
||||||
"""
|
"""
|
||||||
@@ -583,7 +583,11 @@ class PipelineConfig:
|
|||||||
f"using {pipeline_config_cls.__name__} directly without model_index.json"
|
f"using {pipeline_config_cls.__name__} directly without model_index.json"
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
model_info = get_model_info(model_path, backend=kwargs.get("backend"))
|
model_info = get_model_info(
|
||||||
|
model_path,
|
||||||
|
backend=kwargs.get("backend"),
|
||||||
|
model_id=kwargs.get("model_id"),
|
||||||
|
)
|
||||||
if model_info is None:
|
if model_info is None:
|
||||||
from sglang.multimodal_gen.registry import (
|
from sglang.multimodal_gen.registry import (
|
||||||
_PIPELINE_CONFIG_REGISTRY,
|
_PIPELINE_CONFIG_REGISTRY,
|
||||||
@@ -599,7 +603,11 @@ class PipelineConfig:
|
|||||||
)
|
)
|
||||||
pipeline_config_cls = model_info.pipeline_config_cls
|
pipeline_config_cls = model_info.pipeline_config_cls
|
||||||
else:
|
else:
|
||||||
model_info = get_model_info(model_path, backend=kwargs.get("backend"))
|
model_info = get_model_info(
|
||||||
|
model_path,
|
||||||
|
backend=kwargs.get("backend"),
|
||||||
|
model_id=kwargs.get("model_id"),
|
||||||
|
)
|
||||||
if model_info is None:
|
if model_info is None:
|
||||||
raise ValueError(
|
raise ValueError(
|
||||||
f"Could not get model info for '{model_path}'. "
|
f"Could not get model info for '{model_path}'. "
|
||||||
|
|||||||
@@ -14,13 +14,16 @@ import unicodedata
|
|||||||
import uuid
|
import uuid
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from enum import Enum, auto
|
from enum import Enum, auto
|
||||||
from typing import Any
|
from typing import TYPE_CHECKING, Any
|
||||||
|
|
||||||
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.utils import StoreBoolean
|
from sglang.multimodal_gen.utils import StoreBoolean, expand_path_fields
|
||||||
|
|
||||||
logger = init_logger(__name__)
|
logger = init_logger(__name__)
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from sglang.multimodal_gen.runtime.server_args import ServerArgs
|
||||||
|
|
||||||
|
|
||||||
def _json_safe(obj: Any):
|
def _json_safe(obj: Any):
|
||||||
"""
|
"""
|
||||||
@@ -354,6 +357,8 @@ class SamplingParams:
|
|||||||
"""
|
"""
|
||||||
final adjustment, called after merged with user params
|
final adjustment, called after merged with user params
|
||||||
"""
|
"""
|
||||||
|
expand_path_fields(self)
|
||||||
|
|
||||||
# TODO: SamplingParams should not rely on ServerArgs
|
# TODO: SamplingParams should not rely on ServerArgs
|
||||||
pipeline_config = server_args.pipeline_config
|
pipeline_config = server_args.pipeline_config
|
||||||
if not isinstance(self.prompt, str):
|
if not isinstance(self.prompt, str):
|
||||||
@@ -374,11 +379,14 @@ class SamplingParams:
|
|||||||
|
|
||||||
self.data_type = server_args.pipeline_config.task_type.data_type()
|
self.data_type = server_args.pipeline_config.task_type.data_type()
|
||||||
|
|
||||||
if self.output_path is None and server_args.output_path is not None:
|
if self.output_path is None:
|
||||||
|
if server_args.output_path is not None:
|
||||||
self.output_path = server_args.output_path
|
self.output_path = server_args.output_path
|
||||||
logger.debug(
|
logger.debug(
|
||||||
f"Overriding output_path with server configuration: {self.output_path}"
|
f"Overriding output_path with server configuration: {self.output_path}"
|
||||||
)
|
)
|
||||||
|
else:
|
||||||
|
self.save_output = False
|
||||||
|
|
||||||
# Process negative prompt
|
# Process negative prompt
|
||||||
if self.negative_prompt is not None and not self.negative_prompt.isspace():
|
if self.negative_prompt is not None and not self.negative_prompt.isspace():
|
||||||
@@ -504,15 +512,18 @@ class SamplingParams:
|
|||||||
from sglang.multimodal_gen.registry import get_model_info
|
from sglang.multimodal_gen.registry import get_model_info
|
||||||
|
|
||||||
backend = kwargs.pop("backend", None)
|
backend = kwargs.pop("backend", None)
|
||||||
model_info = get_model_info(model_path, backend=backend)
|
model_id = kwargs.pop("model_id", None)
|
||||||
|
model_info = get_model_info(model_path, backend=backend, model_id=model_id)
|
||||||
sampling_params: SamplingParams = model_info.sampling_param_cls(**kwargs)
|
sampling_params: SamplingParams = model_info.sampling_param_cls(**kwargs)
|
||||||
return sampling_params
|
return sampling_params
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def from_user_sampling_params_args(model_path: str, server_args, *args, **kwargs):
|
def from_user_sampling_params_args(
|
||||||
|
model_path: str, server_args: "ServerArgs", *args, **kwargs
|
||||||
|
):
|
||||||
try:
|
try:
|
||||||
sampling_params = SamplingParams.from_pretrained(
|
sampling_params = SamplingParams.from_pretrained(
|
||||||
model_path, backend=server_args.backend
|
model_path, backend=server_args.backend, model_id=server_args.model_id
|
||||||
)
|
)
|
||||||
except (AttributeError, ValueError) as e:
|
except (AttributeError, ValueError) as e:
|
||||||
# Handle safetensors files or other cases where model_index.json is not available
|
# Handle safetensors files or other cases where model_index.json is not available
|
||||||
@@ -890,6 +901,8 @@ class SamplingParams:
|
|||||||
return {attr: getattr(args, attr) for attr in attrs if hasattr(args, attr)}
|
return {attr: getattr(args, attr) for attr in attrs if hasattr(args, attr)}
|
||||||
|
|
||||||
def output_file_path(self):
|
def output_file_path(self):
|
||||||
|
if self.output_path is None:
|
||||||
|
return None
|
||||||
return os.path.join(self.output_path, self.output_file_name)
|
return os.path.join(self.output_path, self.output_file_name)
|
||||||
|
|
||||||
def _merge_with_user_params(self, user_params: "SamplingParams"):
|
def _merge_with_user_params(self, user_params: "SamplingParams"):
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
# 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 contextlib
|
||||||
import os
|
import os
|
||||||
import time
|
import time
|
||||||
from typing import List, Optional
|
from typing import List, Optional
|
||||||
@@ -23,6 +24,7 @@ from sglang.multimodal_gen.runtime.entrypoints.openai.utils import (
|
|||||||
merge_image_input_list,
|
merge_image_input_list,
|
||||||
process_generation_batch,
|
process_generation_batch,
|
||||||
save_image_to_path,
|
save_image_to_path,
|
||||||
|
temp_dir_if_disabled,
|
||||||
)
|
)
|
||||||
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.pipelines_core.schedule_batch import OutputBatch
|
||||||
@@ -53,11 +55,13 @@ def _build_image_response_kwargs(
|
|||||||
b64_list: list[str] | None = None,
|
b64_list: list[str] | None = None,
|
||||||
cloud_url: str | None = None,
|
cloud_url: str | None = None,
|
||||||
fallback_url: str | None = None,
|
fallback_url: str | None = None,
|
||||||
|
is_persistent: bool = True,
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""Build ImageResponse data list.
|
"""Build ImageResponse data list.
|
||||||
|
|
||||||
For b64_json: uses pre-read b64_list (call _read_b64_for_paths first).
|
For b64_json: uses pre-read b64_list (call _read_b64_for_paths first).
|
||||||
For url: uses cloud_url or fallback_url.
|
For url: uses cloud_url or fallback_url.
|
||||||
|
file_path is omitted when is_persistent=False to avoid exposing stale temp paths.
|
||||||
"""
|
"""
|
||||||
ret = None
|
ret = None
|
||||||
if resp_format == "b64_json":
|
if resp_format == "b64_json":
|
||||||
@@ -67,7 +71,7 @@ def _build_image_response_kwargs(
|
|||||||
ImageResponseData(
|
ImageResponseData(
|
||||||
b64_json=b64,
|
b64_json=b64,
|
||||||
revised_prompt=prompt,
|
revised_prompt=prompt,
|
||||||
file_path=os.path.abspath(path),
|
file_path=os.path.abspath(path) if is_persistent else None,
|
||||||
)
|
)
|
||||||
for b64, path in zip(b64_list, save_file_path_list)
|
for b64, path in zip(b64_list, save_file_path_list)
|
||||||
]
|
]
|
||||||
@@ -84,7 +88,11 @@ def _build_image_response_kwargs(
|
|||||||
ImageResponseData(
|
ImageResponseData(
|
||||||
url=url,
|
url=url,
|
||||||
revised_prompt=prompt,
|
revised_prompt=prompt,
|
||||||
file_path=os.path.abspath(save_file_path_list[0]),
|
file_path=(
|
||||||
|
os.path.abspath(save_file_path_list[0])
|
||||||
|
if is_persistent
|
||||||
|
else None
|
||||||
|
),
|
||||||
)
|
)
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
@@ -103,13 +111,17 @@ async def generations(
|
|||||||
request: ImageGenerationsRequest,
|
request: ImageGenerationsRequest,
|
||||||
):
|
):
|
||||||
request_id = generate_request_id()
|
request_id = generate_request_id()
|
||||||
|
server_args = get_global_server_args()
|
||||||
ext = choose_output_image_ext(request.output_format, request.background)
|
ext = choose_output_image_ext(request.output_format, request.background)
|
||||||
|
|
||||||
|
with temp_dir_if_disabled(server_args.output_path) as output_dir:
|
||||||
sampling = build_sampling_params(
|
sampling = build_sampling_params(
|
||||||
request_id,
|
request_id,
|
||||||
prompt=request.prompt,
|
prompt=request.prompt,
|
||||||
size=request.size,
|
size=request.size,
|
||||||
num_outputs_per_prompt=max(1, min(int(request.n or 1), 10)),
|
num_outputs_per_prompt=max(1, min(int(request.n or 1), 10)),
|
||||||
output_file_name=f"{request_id}.{ext}",
|
output_file_name=f"{request_id}.{ext}",
|
||||||
|
output_path=output_dir,
|
||||||
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,
|
||||||
@@ -121,7 +133,7 @@ async def generations(
|
|||||||
output_quality=request.output_quality,
|
output_quality=request.output_quality,
|
||||||
)
|
)
|
||||||
batch = prepare_request(
|
batch = prepare_request(
|
||||||
server_args=get_global_server_args(),
|
server_args=server_args,
|
||||||
sampling_params=sampling,
|
sampling_params=sampling,
|
||||||
)
|
)
|
||||||
# Add diffusers_kwargs if provided
|
# Add diffusers_kwargs if provided
|
||||||
@@ -136,17 +148,20 @@ async def generations(
|
|||||||
|
|
||||||
# read b64 before cloud upload may delete the local file
|
# read b64 before cloud upload may delete the local file
|
||||||
b64_list = (
|
b64_list = (
|
||||||
_read_b64_for_paths(save_file_path_list) if resp_format == "b64_json" else None
|
_read_b64_for_paths(save_file_path_list)
|
||||||
|
if resp_format == "b64_json"
|
||||||
|
else None
|
||||||
)
|
)
|
||||||
|
|
||||||
cloud_url = await cloud_storage.upload_and_cleanup(save_file_path)
|
cloud_url = await cloud_storage.upload_and_cleanup(save_file_path)
|
||||||
|
|
||||||
|
is_persistent = server_args.output_path is not None
|
||||||
await IMAGE_STORE.upsert(
|
await IMAGE_STORE.upsert(
|
||||||
request_id,
|
request_id,
|
||||||
{
|
{
|
||||||
"id": request_id,
|
"id": request_id,
|
||||||
"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 or not is_persistent else save_file_path,
|
||||||
"url": cloud_url,
|
"url": cloud_url,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
@@ -159,8 +174,8 @@ async def generations(
|
|||||||
result,
|
result,
|
||||||
b64_list=b64_list,
|
b64_list=b64_list,
|
||||||
cloud_url=cloud_url,
|
cloud_url=cloud_url,
|
||||||
# Provide a local fallback URL when cloud upload is unavailable, mirroring /v1/images/edits.
|
fallback_url=f"/v1/images/{request_id}/content" if is_persistent else None,
|
||||||
fallback_url=f"/v1/images/{request_id}/content",
|
is_persistent=is_persistent,
|
||||||
)
|
)
|
||||||
|
|
||||||
return ImageResponse(**response_kwargs)
|
return ImageResponse(**response_kwargs)
|
||||||
@@ -193,6 +208,7 @@ async def edits(
|
|||||||
num_frames: int = Form(1),
|
num_frames: int = Form(1),
|
||||||
):
|
):
|
||||||
request_id = generate_request_id()
|
request_id = generate_request_id()
|
||||||
|
server_args = get_global_server_args()
|
||||||
# Resolve images from either `image` or `image[]` (OpenAI SDK sends `image[]` when list is provided)
|
# Resolve images from either `image` or `image[]` (OpenAI SDK sends `image[]` when list is provided)
|
||||||
images = image or image_array
|
images = image or image_array
|
||||||
urls = url or url_array
|
urls = url or url_array
|
||||||
@@ -202,23 +218,27 @@ async def edits(
|
|||||||
status_code=422, detail="Field 'image' or 'url' is required"
|
status_code=422, detail="Field 'image' or 'url' is required"
|
||||||
)
|
)
|
||||||
|
|
||||||
# Save all input images; additional images beyond the first are saved for potential future use
|
|
||||||
uploads_dir = os.path.join("inputs", "uploads")
|
|
||||||
os.makedirs(uploads_dir, exist_ok=True)
|
|
||||||
|
|
||||||
image_list = merge_image_input_list(images, urls)
|
image_list = merge_image_input_list(images, urls)
|
||||||
|
|
||||||
|
with contextlib.ExitStack() as stack:
|
||||||
|
uploads_dir = stack.enter_context(
|
||||||
|
temp_dir_if_disabled(server_args.input_save_path)
|
||||||
|
)
|
||||||
|
output_dir = stack.enter_context(temp_dir_if_disabled(server_args.output_path))
|
||||||
|
|
||||||
input_paths = []
|
input_paths = []
|
||||||
try:
|
try:
|
||||||
for idx, img in enumerate(image_list):
|
for idx, img in enumerate(image_list):
|
||||||
filename = img.filename if hasattr(img, "filename") else f"image_{idx}"
|
filename = img.filename if hasattr(img, "filename") else f"image_{idx}"
|
||||||
input_path = await save_image_to_path(
|
input_path = await save_image_to_path(
|
||||||
img, os.path.join(uploads_dir, f"{request_id}_{idx}_{filename}")
|
img,
|
||||||
|
os.path.join(uploads_dir, f"{request_id}_{idx}_{filename}"),
|
||||||
)
|
)
|
||||||
input_paths.append(input_path)
|
input_paths.append(input_path)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=400, detail=f"Failed to process image source: {str(e)}"
|
status_code=400,
|
||||||
|
detail=f"Failed to process image source: {str(e)}",
|
||||||
)
|
)
|
||||||
|
|
||||||
ext = choose_output_image_ext(output_format, background)
|
ext = choose_output_image_ext(output_format, background)
|
||||||
@@ -228,6 +248,7 @@ async def edits(
|
|||||||
size=size,
|
size=size,
|
||||||
num_outputs_per_prompt=max(1, min(int(n or 1), 10)),
|
num_outputs_per_prompt=max(1, min(int(n or 1), 10)),
|
||||||
output_file_name=f"{request_id}.{ext}",
|
output_file_name=f"{request_id}.{ext}",
|
||||||
|
output_path=output_dir,
|
||||||
image_path=input_paths,
|
image_path=input_paths,
|
||||||
seed=seed,
|
seed=seed,
|
||||||
generator_device=generator_device,
|
generator_device=generator_device,
|
||||||
@@ -241,7 +262,7 @@ async def edits(
|
|||||||
output_quality=output_quality,
|
output_quality=output_quality,
|
||||||
)
|
)
|
||||||
batch = prepare_request(
|
batch = prepare_request(
|
||||||
server_args=get_global_server_args(),
|
server_args=server_args,
|
||||||
sampling_params=sampling,
|
sampling_params=sampling,
|
||||||
)
|
)
|
||||||
save_file_path_list, result = await process_generation_batch(
|
save_file_path_list, result = await process_generation_batch(
|
||||||
@@ -252,19 +273,23 @@ async def edits(
|
|||||||
|
|
||||||
# read b64 before cloud upload may delete the local file
|
# read b64 before cloud upload may delete the local file
|
||||||
b64_list = (
|
b64_list = (
|
||||||
_read_b64_for_paths(save_file_path_list) if resp_format == "b64_json" else None
|
_read_b64_for_paths(save_file_path_list)
|
||||||
|
if resp_format == "b64_json"
|
||||||
|
else None
|
||||||
)
|
)
|
||||||
|
|
||||||
cloud_url = await cloud_storage.upload_and_cleanup(save_file_path)
|
cloud_url = await cloud_storage.upload_and_cleanup(save_file_path)
|
||||||
|
|
||||||
|
is_persistent = server_args.output_path is not None
|
||||||
|
is_input_persistent = server_args.input_save_path is not None
|
||||||
await IMAGE_STORE.upsert(
|
await IMAGE_STORE.upsert(
|
||||||
request_id,
|
request_id,
|
||||||
{
|
{
|
||||||
"id": request_id,
|
"id": request_id,
|
||||||
"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 or not is_persistent else save_file_path,
|
||||||
"url": cloud_url,
|
"url": cloud_url,
|
||||||
"input_image_paths": input_paths,
|
"input_image_paths": input_paths if is_input_persistent else None,
|
||||||
"num_input_images": len(input_paths),
|
"num_input_images": len(input_paths),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
@@ -277,7 +302,8 @@ async def edits(
|
|||||||
result,
|
result,
|
||||||
b64_list=b64_list,
|
b64_list=b64_list,
|
||||||
cloud_url=cloud_url,
|
cloud_url=cloud_url,
|
||||||
fallback_url=f"/v1/images/{request_id}/content",
|
fallback_url=f"/v1/images/{request_id}/content" if is_persistent else None,
|
||||||
|
is_persistent=is_persistent,
|
||||||
)
|
)
|
||||||
|
|
||||||
return ImageResponse(**response_kwargs)
|
return ImageResponse(**response_kwargs)
|
||||||
@@ -298,7 +324,12 @@ async def download_image_content(
|
|||||||
)
|
)
|
||||||
|
|
||||||
file_path = item.get("file_path")
|
file_path = item.get("file_path")
|
||||||
if not file_path or not os.path.exists(file_path):
|
if not file_path:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=404,
|
||||||
|
detail="Image was not persisted on disk (output_path is disabled). Use b64_json response_format or configure cloud storage.",
|
||||||
|
)
|
||||||
|
if not os.path.exists(file_path):
|
||||||
raise HTTPException(status_code=404, detail="Image is still being generated")
|
raise HTTPException(status_code=404, detail="Image is still being generated")
|
||||||
|
|
||||||
ext = os.path.splitext(file_path)[1].lower()
|
ext = os.path.splitext(file_path)[1].lower()
|
||||||
|
|||||||
@@ -2,8 +2,11 @@
|
|||||||
import base64
|
import base64
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
|
import shutil
|
||||||
|
import tempfile
|
||||||
import time
|
import time
|
||||||
from typing import Any, List, Optional, Union
|
from contextlib import contextmanager
|
||||||
|
from typing import Any, Generator, List, Optional, Union
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
from fastapi import UploadFile
|
from fastapi import UploadFile
|
||||||
@@ -47,6 +50,23 @@ DEFAULT_FPS = 24
|
|||||||
DEFAULT_VIDEO_SECONDS = 4
|
DEFAULT_VIDEO_SECONDS = 4
|
||||||
|
|
||||||
|
|
||||||
|
@contextmanager
|
||||||
|
def temp_dir_if_disabled(
|
||||||
|
configured_path: str | None,
|
||||||
|
) -> Generator[str, None, None]:
|
||||||
|
"""Yield *configured_path* when it is set, otherwise create a temporary
|
||||||
|
directory that is automatically removed when the context exits."""
|
||||||
|
if configured_path is not None:
|
||||||
|
os.makedirs(configured_path, exist_ok=True)
|
||||||
|
yield configured_path
|
||||||
|
else:
|
||||||
|
tmp = tempfile.mkdtemp(prefix="sglang_")
|
||||||
|
try:
|
||||||
|
yield tmp
|
||||||
|
finally:
|
||||||
|
shutil.rmtree(tmp, ignore_errors=True)
|
||||||
|
|
||||||
|
|
||||||
def _parse_size(size: str) -> tuple[int, int] | tuple[None, None]:
|
def _parse_size(size: str) -> tuple[int, int] | tuple[None, None]:
|
||||||
try:
|
try:
|
||||||
parts = size.lower().replace(" ", "").split("x")
|
parts = size.lower().replace(" ", "").split("x")
|
||||||
|
|||||||
@@ -3,6 +3,8 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
|
import shutil
|
||||||
|
import tempfile
|
||||||
import time
|
import time
|
||||||
from typing import Any, Dict, Optional
|
from typing import Any, Dict, Optional
|
||||||
|
|
||||||
@@ -99,14 +101,15 @@ def _video_job_from_sampling(
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
async def _save_first_input_image(image_sources, request_id: str) -> str | None:
|
async def _save_first_input_image(
|
||||||
|
image_sources, request_id: str, uploads_dir: str
|
||||||
|
) -> str | None:
|
||||||
"""Save the first input image from a list of sources and return its path."""
|
"""Save the first input image from a list of sources and return its path."""
|
||||||
image_list = merge_image_input_list(image_sources)
|
image_list = merge_image_input_list(image_sources)
|
||||||
if not image_list:
|
if not image_list:
|
||||||
return None
|
return None
|
||||||
image = image_list[0]
|
image = image_list[0]
|
||||||
|
|
||||||
uploads_dir = os.path.join("inputs", "uploads")
|
|
||||||
os.makedirs(uploads_dir, exist_ok=True)
|
os.makedirs(uploads_dir, exist_ok=True)
|
||||||
|
|
||||||
filename = image.filename if hasattr(image, "filename") else "url_image"
|
filename = image.filename if hasattr(image, "filename") else "url_image"
|
||||||
@@ -114,7 +117,13 @@ async def _save_first_input_image(image_sources, request_id: str) -> str | None:
|
|||||||
return await save_image_to_path(image, target_path)
|
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,
|
||||||
|
*,
|
||||||
|
temp_dirs: list[str] | None = None,
|
||||||
|
output_persistent: bool = True,
|
||||||
|
) -> None:
|
||||||
from sglang.multimodal_gen.runtime.scheduler_client import async_scheduler_client
|
from sglang.multimodal_gen.runtime.scheduler_client import async_scheduler_client
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -125,12 +134,15 @@ async def _dispatch_job_async(job_id: str, batch: Req) -> None:
|
|||||||
|
|
||||||
cloud_url = await cloud_storage.upload_and_cleanup(save_file_path)
|
cloud_url = await cloud_storage.upload_and_cleanup(save_file_path)
|
||||||
|
|
||||||
|
persistent_path = (
|
||||||
|
save_file_path if not cloud_url and output_persistent else None
|
||||||
|
)
|
||||||
update_fields = {
|
update_fields = {
|
||||||
"status": "completed",
|
"status": "completed",
|
||||||
"progress": 100,
|
"progress": 100,
|
||||||
"completed_at": int(time.time()),
|
"completed_at": int(time.time()),
|
||||||
"url": cloud_url,
|
"url": cloud_url,
|
||||||
"file_path": save_file_path if not cloud_url else None,
|
"file_path": persistent_path,
|
||||||
}
|
}
|
||||||
update_fields = add_common_data_to_response(
|
update_fields = add_common_data_to_response(
|
||||||
update_fields, request_id=job_id, result=result
|
update_fields, request_id=job_id, result=result
|
||||||
@@ -141,6 +153,9 @@ async def _dispatch_job_async(job_id: str, batch: Req) -> None:
|
|||||||
await VIDEO_STORE.update_fields(
|
await VIDEO_STORE.update_fields(
|
||||||
job_id, {"status": "failed", "error": {"message": str(e)}}
|
job_id, {"status": "failed", "error": {"message": str(e)}}
|
||||||
)
|
)
|
||||||
|
finally:
|
||||||
|
for td in temp_dirs or []:
|
||||||
|
shutil.rmtree(td, ignore_errors=True)
|
||||||
|
|
||||||
|
|
||||||
# TODO: support image to video generation
|
# TODO: support image to video generation
|
||||||
@@ -176,6 +191,22 @@ async def create_video(
|
|||||||
server_args = get_global_server_args()
|
server_args = get_global_server_args()
|
||||||
task_type = server_args.pipeline_config.task_type
|
task_type = server_args.pipeline_config.task_type
|
||||||
|
|
||||||
|
# Resolve input upload directory (may be a temp dir when saving is disabled)
|
||||||
|
temp_dirs: list[str] = []
|
||||||
|
if server_args.input_save_path is not None:
|
||||||
|
uploads_dir = server_args.input_save_path
|
||||||
|
os.makedirs(uploads_dir, exist_ok=True)
|
||||||
|
else:
|
||||||
|
uploads_dir = tempfile.mkdtemp(prefix="sglang_input_")
|
||||||
|
temp_dirs.append(uploads_dir)
|
||||||
|
|
||||||
|
# Resolve output directory
|
||||||
|
effective_output_path = server_args.output_path
|
||||||
|
output_persistent = True
|
||||||
|
if "multipart/form-data" not in content_type:
|
||||||
|
# JSON body may carry a per-request output_path; checked after parsing below
|
||||||
|
pass
|
||||||
|
|
||||||
if "multipart/form-data" in content_type:
|
if "multipart/form-data" in content_type:
|
||||||
if not prompt:
|
if not prompt:
|
||||||
raise HTTPException(status_code=400, detail="prompt is required")
|
raise HTTPException(status_code=400, detail="prompt is required")
|
||||||
@@ -187,7 +218,9 @@ async def create_video(
|
|||||||
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",
|
||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
input_path = await _save_first_input_image(image_sources, request_id)
|
input_path = await _save_first_input_image(
|
||||||
|
image_sources, request_id, uploads_dir
|
||||||
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=400, detail=f"Failed to process image source: {str(e)}"
|
status_code=400, detail=f"Failed to process image source: {str(e)}"
|
||||||
@@ -258,7 +291,7 @@ async def create_video(
|
|||||||
if payload.get("reference_url"):
|
if payload.get("reference_url"):
|
||||||
try:
|
try:
|
||||||
input_path = await _save_first_input_image(
|
input_path = await _save_first_input_image(
|
||||||
payload.get("reference_url"), request_id
|
payload.get("reference_url"), request_id, uploads_dir
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
@@ -270,6 +303,17 @@ async def create_video(
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise HTTPException(status_code=400, detail=f"Invalid request body: {e}")
|
raise HTTPException(status_code=400, detail=f"Invalid request body: {e}")
|
||||||
|
|
||||||
|
# Resolve per-request output_path override
|
||||||
|
effective_output_path = req.output_path or server_args.output_path
|
||||||
|
if effective_output_path is None:
|
||||||
|
output_tmp = tempfile.mkdtemp(prefix="sglang_output_")
|
||||||
|
temp_dirs.append(output_tmp)
|
||||||
|
effective_output_path = output_tmp
|
||||||
|
output_persistent = False
|
||||||
|
|
||||||
|
# Inject resolved output_path so _build_video_sampling_params picks it up
|
||||||
|
req.output_path = effective_output_path
|
||||||
|
|
||||||
logger.debug(f"Server received from create_video endpoint: req={req}")
|
logger.debug(f"Server received from create_video endpoint: req={req}")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -289,7 +333,14 @@ async def create_video(
|
|||||||
if req.diffusers_kwargs:
|
if req.diffusers_kwargs:
|
||||||
batch.extra["diffusers_kwargs"] = req.diffusers_kwargs
|
batch.extra["diffusers_kwargs"] = req.diffusers_kwargs
|
||||||
# Enqueue the job asynchronously and return immediately
|
# Enqueue the job asynchronously and return immediately
|
||||||
asyncio.create_task(_dispatch_job_async(request_id, batch))
|
asyncio.create_task(
|
||||||
|
_dispatch_job_async(
|
||||||
|
request_id,
|
||||||
|
batch,
|
||||||
|
temp_dirs=temp_dirs or None,
|
||||||
|
output_persistent=output_persistent,
|
||||||
|
)
|
||||||
|
)
|
||||||
return VideoResponse(**job)
|
return VideoResponse(**job)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -247,11 +247,9 @@ class Req:
|
|||||||
base, ext = os.path.splitext(output_file_name)
|
base, ext = os.path.splitext(output_file_name)
|
||||||
output_file_name = f"{base}_{output_idx}{ext}"
|
output_file_name = f"{base}_{output_idx}{ext}"
|
||||||
|
|
||||||
return (
|
if self.output_path is None or not output_file_name:
|
||||||
os.path.join(self.output_path, output_file_name)
|
return None
|
||||||
if output_file_name
|
return os.path.join(self.output_path, output_file_name)
|
||||||
else None
|
|
||||||
)
|
|
||||||
|
|
||||||
def set_as_warmup(self):
|
def set_as_warmup(self):
|
||||||
self.is_warmup = True
|
self.is_warmup = True
|
||||||
|
|||||||
@@ -39,7 +39,11 @@ from sglang.multimodal_gen.runtime.utils.logging_utils import (
|
|||||||
configure_logger,
|
configure_logger,
|
||||||
init_logger,
|
init_logger,
|
||||||
)
|
)
|
||||||
from sglang.multimodal_gen.utils import FlexibleArgumentParser, StoreBoolean
|
from sglang.multimodal_gen.utils import (
|
||||||
|
FlexibleArgumentParser,
|
||||||
|
StoreBoolean,
|
||||||
|
expand_path_fields,
|
||||||
|
)
|
||||||
|
|
||||||
logger = init_logger(__name__)
|
logger = init_logger(__name__)
|
||||||
|
|
||||||
@@ -293,6 +297,7 @@ class ServerArgs:
|
|||||||
scheduler_port: int = 5555
|
scheduler_port: int = 5555
|
||||||
|
|
||||||
output_path: str | None = "outputs/"
|
output_path: str | None = "outputs/"
|
||||||
|
input_save_path: str | None = "inputs/uploads"
|
||||||
|
|
||||||
# Prompt text file for batch processing
|
# Prompt text file for batch processing
|
||||||
prompt_file_path: str | None = None
|
prompt_file_path: str | None = None
|
||||||
@@ -332,7 +337,8 @@ class ServerArgs:
|
|||||||
return self.host is None or self.port is None
|
return self.host is None or self.port is None
|
||||||
|
|
||||||
def _adjust_path(self):
|
def _adjust_path(self):
|
||||||
self.model_path = os.path.expanduser(self.model_path)
|
expand_path_fields(self)
|
||||||
|
self._adjust_save_paths()
|
||||||
|
|
||||||
def _adjust_parameters(self):
|
def _adjust_parameters(self):
|
||||||
"""set defaults and normalize values."""
|
"""set defaults and normalize values."""
|
||||||
@@ -354,6 +360,13 @@ class ServerArgs:
|
|||||||
self._validate_parallelism()
|
self._validate_parallelism()
|
||||||
self._validate_cfg_parallel()
|
self._validate_cfg_parallel()
|
||||||
|
|
||||||
|
def _adjust_save_paths(self):
|
||||||
|
"""Normalize empty-string save paths to None (disabled)."""
|
||||||
|
if self.output_path is not None and self.output_path.strip() == "":
|
||||||
|
self.output_path = None
|
||||||
|
if self.input_save_path is not None and self.input_save_path.strip() == "":
|
||||||
|
self.input_save_path = None
|
||||||
|
|
||||||
def _adjust_quant_config(self):
|
def _adjust_quant_config(self):
|
||||||
"""validate and adjust"""
|
"""validate and adjust"""
|
||||||
|
|
||||||
@@ -838,7 +851,13 @@ class ServerArgs:
|
|||||||
"--output-path",
|
"--output-path",
|
||||||
type=str,
|
type=str,
|
||||||
default=ServerArgs.output_path,
|
default=ServerArgs.output_path,
|
||||||
help="Directory path to save generated images/videos",
|
help='Directory path to save generated images/videos. Set to "" to disable persistent saving.',
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--input-save-path",
|
||||||
|
type=str,
|
||||||
|
default=ServerArgs.input_save_path,
|
||||||
|
help='Directory path to save uploaded input images/videos. Set to "" to disable persistent saving.',
|
||||||
)
|
)
|
||||||
|
|
||||||
# LoRA
|
# LoRA
|
||||||
|
|||||||
@@ -34,6 +34,24 @@ logger = init_logger(__name__)
|
|||||||
|
|
||||||
T = TypeVar("T")
|
T = TypeVar("T")
|
||||||
|
|
||||||
|
|
||||||
|
def expand_path_fields(obj) -> None:
|
||||||
|
"""In-place expanduser on all dataclass fields whose name ends with '_path' or '_paths'."""
|
||||||
|
eu = os.path.expanduser
|
||||||
|
for f in fields(obj):
|
||||||
|
v = getattr(obj, f.name)
|
||||||
|
if f.name.endswith("_path") and isinstance(v, str):
|
||||||
|
setattr(obj, f.name, eu(v))
|
||||||
|
elif f.name.endswith("_path") and isinstance(v, list):
|
||||||
|
setattr(obj, f.name, [eu(x) if isinstance(x, str) else x for x in v])
|
||||||
|
elif f.name.endswith("_paths") and isinstance(v, dict):
|
||||||
|
setattr(
|
||||||
|
obj,
|
||||||
|
f.name,
|
||||||
|
{k: eu(p) if isinstance(p, str) else p for k, p in v.items()},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
# TODO(will): used to convert server_args.precision to torch.dtype. Find a
|
# TODO(will): used to convert server_args.precision to torch.dtype. Find a
|
||||||
# cleaner way to do this.
|
# cleaner way to do this.
|
||||||
PRECISION_TO_TYPE = {
|
PRECISION_TO_TYPE = {
|
||||||
|
|||||||
Reference in New Issue
Block a user