multimodalart's picture
multimodalart HF Staff
Sync the split MiniMax-H3 Spaces
186aa49 verified
Raw
History Blame Contribute Delete
48.4 kB
# Copyright 2026 The HuggingFace Team. All rights reserved.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""`diffusers-cli run` — single agentic entry point.
Runs any diffusers pipeline (standard or modular) by forwarding `--pipeline-kwargs` verbatim, saves the output by
detecting its runtime type, and can submit the same call to an HF Sandbox via `--remote`.
"""
from __future__ import annotations
import json
import os
import sys
from argparse import ArgumentParser, Namespace, _SubParsersAction
from pathlib import Path
from typing import Any
from huggingface_hub.cli._output import out
from diffusers.models.attention_dispatch import _HUB_KERNELS_REGISTRY
from diffusers.utils import load_image, load_video, logging
from . import BaseDiffusersCLICommand
logger = logging.get_logger("diffusers-cli/run")
# ---------------------------------------------------------------------------
# Constants
# ---------------------------------------------------------------------------
DEFAULT_OUTPUT_DIR = str(Path.home() / ".diffusers" / "cli" / "run" / "outputs")
DTYPE_CHOICES = ("auto", "float16", "fp16", "bfloat16", "bf16", "float32", "fp32")
CPU_OFFLOAD_CHOICES = ("model", "group")
ATTENTION_BACKEND_CHOICES = ("default", *sorted(b.value for b in _HUB_KERNELS_REGISTRY))
# Kwarg keys whose string value gets auto-loaded before being passed to the pipeline call.
# Images resolve via `diffusers.utils.load_image` → PIL.Image.Image; videos resolve via
# `diffusers.utils.load_video` → list[PIL.Image.Image].
_IMAGE_INPUT_KEYS = (
"image",
"mask_image",
"control_image",
"ip_adapter_image",
"image_2",
)
_VIDEO_INPUT_KEYS = (
"video",
"control_video",
)
_AUDIO_INPUT_KEYS = (
"initial_audio_waveforms",
"reference_audio",
"src_audio",
)
# Pipeline attribute prefixes that identify a denoiser submodule. Matches base names
# (`transformer`, `unet`) and their numbered variants (`transformer_2`, etc.).
_DENOISER_COMPONENT_KEYS = ("transformer", "unet")
_DEFAULT_REMOTE_DEPS = (
"diffusers",
"accelerate",
"transformers",
"safetensors",
"sentencepiece", # required by several text-encoder tokenizers (T5, LLaMA, …)
"ftfy", # required by older CLIP text-encoder paths
)
# Base sandbox image — provides torch + CUDA so `uv pip install --system`
# only has to add the small Python deps. cuda12.8 is the highest cuda12.x tag
# below the HF Jobs host driver's CUDA 12.9 max.
_DEFAULT_REMOTE_IMAGE = "pytorch/pytorch:2.10.0-cuda12.8-cudnn9-runtime"
# Installed console-script name invoked inside the sandbox after the deps land.
_CONTAINER_CLI_BINARY = "diffusers-cli"
# Working directories inside the sandbox: local media from `--pipeline-kwargs` is uploaded
# under _SANDBOX_INPUTS_DIR, and the sandbox CLI is told to write its outputs under
# _SANDBOX_OUTPUTS_DIR so we can download them back afterwards.
_SANDBOX_INPUTS_DIR = "/tmp/diffusers-cli/inputs"
_SANDBOX_OUTPUTS_DIR = "/tmp/diffusers-cli/outputs"
RUN_ID_ENV = "DIFFUSERS_CLI_RUN_ID"
# Namespace keys that control *how* a remote run is dispatched, not what the sandbox CLI
# runs. They are stripped when forwarding argv to the sandbox.
REMOTE_KEYS = frozenset(
{
"remote",
"flavor",
"timeout",
"dependencies",
"namespace",
"image",
"keep_alive",
"sandbox_id",
"idle_timeout",
"volume",
"func",
"format", # top-level --format is a local rendering flag; never forward to the sandbox
}
)
# ---------------------------------------------------------------------------
# Argparse helpers
# ---------------------------------------------------------------------------
def _add_loading_arguments(parser: ArgumentParser) -> None:
parser.add_argument("--model", "-m", required=True, help="Model id on the Hugging Face Hub or local path.")
parser.add_argument(
"--device-map",
default=None,
help=(
"Component placement. Accepts a torch device string (`cuda`, `cuda:0`, `cpu`, `mps`), "
"`balanced` for pipeline-level auto-split across visible GPUs, or a JSON dict of "
'`{"<component>": <device>}` for explicit per-component placement. Auto-detected if omitted.'
),
)
parser.add_argument("--dtype", default="auto", choices=DTYPE_CHOICES, help="Torch dtype for pipeline weights.")
parser.add_argument("--variant", default=None, help='Optional weight variant (e.g. "fp16").')
parser.add_argument("--revision", default=None, help="Model revision (branch, tag, or commit SHA).")
parser.add_argument("--token", default=None, help="Hugging Face token for gated/private models.")
parser.add_argument("--trust-remote-code", action="store_true", help="Allow custom code from the Hub.")
parser.add_argument(
"--lora",
action="append",
default=None,
metavar="JSON",
help=(
"JSON dict describing a LoRA adapter to attach after the pipeline loads. Repeat to stack "
'multiple adapters. Format: \'{"lora_id": "<id>", "lora_scale": <float>}\'. `lora_scale` '
"defaults to 1.0; `adapter_name` is optional (auto-generated as `lora_<i>` when stacking)."
),
)
def _add_optimization_arguments(parser: ArgumentParser) -> None:
parser.add_argument(
"--cpu-offload",
choices=CPU_OFFLOAD_CHOICES,
default=None,
help=(
"Offload pipeline components to CPU during inference. "
"'model' uses enable_model_cpu_offload, "
"'group' uses pipeline.enable_group_offload(leaf_level, use_stream=True)."
),
)
parser.add_argument(
"--attention-backend",
choices=ATTENTION_BACKEND_CHOICES,
default="default",
help=(
"Override the attention backend on the transformer/UNet. "
"Only Hub-hosted kernels are exposed — they auto-download on first use."
),
)
parser.add_argument("--vae-tiling", action="store_true", help="Enable VAE tiling (lower peak VRAM).")
parser.add_argument("--vae-slicing", action="store_true", help="Enable VAE slicing (lower peak VRAM).")
parser.add_argument(
"--context-parallel",
action="store_true",
help=(
"Enable Ulysses-style context parallelism (ulysses_anything mode). "
"Requires a DiT-based pipeline and launching the CLI under torchrun with ≥2 GPUs."
),
)
parser.add_argument(
"--compile",
nargs="?",
const='{"fullgraph": true}',
default=None,
metavar="JSON",
help=(
"torch.compile every denoiser submodule on the pipeline. Accepts an optional JSON "
'object of kwargs forwarded to `torch.compile`, e.g. \'{"mode": "max-autotune", '
'"fullgraph": true}\'. Bare `--compile` uses `fullgraph=true`. Adds a one-time '
"compilation cost on the first step but speeds up every subsequent step — worth it "
"for multi-step generation (50+ steps)."
),
)
def _add_output_arguments(parser: ArgumentParser) -> None:
parser.add_argument(
"--output",
"-o",
default=None,
help=(
"Output file or directory. Defaults to "
"~/.diffusers/cli/run/outputs/diffusers-run-<YYYYMMDDTHHMMSS>-<short-uuid>/<NNNN>.<ext>."
),
)
parser.add_argument(
"--push-to",
default=None,
help=(
"Upload the generated files to this HF bucket after saving (created if missing). Accepts "
"an HF bucket id (`<namespace>/<name>`), an `hf://buckets/<namespace>/<name>[/<subpath>]` "
"URI, or a browser URL for the same — a subpath is used as a folder prefix. Under --remote "
"the upload runs inside the sandbox; without an explicit --output the bucket becomes the "
"sole destination and nothing is downloaded back."
),
)
def _add_remote_arguments(parser: ArgumentParser) -> None:
parser.add_argument(
"--remote",
action="store_true",
help="Run this command in a Hugging Face Sandbox instead of on the local machine.",
)
parser.add_argument(
"--flavor",
default="a10g-small",
help="HF Sandbox hardware flavor for --remote (e.g. a10g-small, a100-large, cpu-basic).",
)
parser.add_argument(
"--timeout",
default="10m",
help="Max wallclock for the run command inside the sandbox (e.g. 30m, 2h). Defaults to 10m.",
)
parser.add_argument(
"--dependencies",
action="append",
default=None,
help="Extra pip dependencies to install in the sandbox. Repeat to add multiple.",
)
parser.add_argument(
"--namespace",
default=None,
help="HF namespace to create the sandbox under (defaults to the current user).",
)
parser.add_argument(
"--image",
default=None,
help=(
"Sandbox image for --remote (defaults to "
f"{_DEFAULT_REMOTE_IMAGE!r}). Must provide torch + CUDA; the CLI installs the "
"small Python deps on top via `uv pip install --system`."
),
)
parser.add_argument(
"--keep-alive",
action="store_true",
help=(
"Don't terminate the sandbox after the run. Its id is printed so a later --remote run "
"can reconnect with --sandbox-id and reuse the warm deps/weights/compile cache."
),
)
parser.add_argument(
"--sandbox-id",
default=None,
help=(
"Reconnect to an existing sandbox (from a prior --keep-alive run) instead of creating a new "
"one, reusing its warm deps/weights/compile cache. Implies --keep-alive; stop it with "
"`hf sandbox kill <id>`."
),
)
parser.add_argument(
"--idle-timeout",
default="10m",
help=(
"Auto-shutdown the sandbox after this much inactivity (e.g. 30m, 1h). Defaults to 10m. "
"Only applied on new sandbox creation — ignored when reconnecting via --sandbox-id."
),
)
parser.add_argument(
"--volume",
action="append",
default=None,
metavar="BUCKET_ID[:MOUNT_PATH]",
help=(
"Mount an HF bucket into the sandbox as a read-write directory. Repeatable. Format: "
"`<namespace>/<name>` (mounts at `/mnt/buckets/<namespace>/<name>`) or "
"`<namespace>/<name>:/some/path` for a custom path. Reference mounted files from "
"--pipeline-kwargs like any other local path. Applied only on new sandbox creation — "
"ignored when reconnecting via --sandbox-id."
),
)
# ---------------------------------------------------------------------------
# Pipeline loading + optimization
# ---------------------------------------------------------------------------
def _resolve_dtype(name: str | None):
if name in (None, "auto"):
return "auto"
import torch
mapping = {
"fp32": torch.float32,
"float32": torch.float32,
"fp16": torch.float16,
"float16": torch.float16,
"bf16": torch.bfloat16,
"bfloat16": torch.bfloat16,
}
if name not in mapping:
raise ValueError(f"Unknown dtype: {name}")
return mapping[name]
def _resolve_device_map(raw: str | None) -> str | dict:
"""Parse `--device-map` into a value acceptable by `from_pretrained(device_map=...)`.
Returns a JSON dict if the value looks like one, `"balanced"` verbatim, or a single-device string (e.g. `"cuda"`,
`"cuda:1"`, `"cpu"`, `"mps"`). Auto-detects when `raw is None`, pinning to `cuda:$LOCAL_RANK` under torchrun.
"""
if raw is None:
from diffusers.utils.torch_utils import torch_device
if torch_device == "cuda":
local_rank = os.environ.get("LOCAL_RANK")
if local_rank is not None:
import torch
torch.cuda.set_device(int(local_rank))
return f"cuda:{local_rank}"
return torch_device
if raw.strip().startswith("{"):
try:
parsed = json.loads(raw)
except json.JSONDecodeError as e:
raise SystemExit(f"--device-map must be a device string or a JSON dict: {e}") from e
if not isinstance(parsed, dict):
raise SystemExit("--device-map JSON must decode to an object.")
return parsed
return raw
def _apply_cpu_offload(pipeline: Any, mode: str, device_map: str | dict) -> None:
"""Apply model or group CPU offload. Requires a single-device target (not balanced or dict)."""
if not isinstance(device_map, str) or device_map == "balanced":
raise SystemExit(
"--cpu-offload requires --device-map to be a single device string (e.g. 'cuda'); "
f"got {device_map!r}. balanced/dict placement is incompatible with CPU offload."
)
if mode == "model":
pipeline.enable_model_cpu_offload(device=device_map)
elif mode == "group":
import torch
pipeline.enable_group_offload(
onload_device=torch.device(device_map),
offload_type="leaf_level",
use_stream=True,
)
def _set_attention_backend(pipeline: Any, backend: str) -> None:
transformer = getattr(pipeline, "transformer", None)
if transformer is None or not hasattr(transformer, "set_attention_backend"):
logger.warning(
f"--attention-backend is only supported on transformer-based pipelines; "
f"{type(pipeline).__name__} uses the legacy UNet attention path."
)
return
try:
transformer.set_attention_backend(backend)
except (ValueError, ImportError, RuntimeError) as e:
logger.warning(
f"Attention backend {backend!r} could not be set on {type(transformer).__name__}: "
f"{type(e).__name__}: {e}. Falling back to the model's default backend."
)
def _enable_context_parallel(pipeline: Any) -> None:
import torch
if not torch.distributed.is_available():
raise SystemExit("--context-parallel requires a torch build with distributed support.")
if not torch.distributed.is_initialized():
# Hybrid backend: ulysses_anything's per-rank size coordination wants Gloo on CPU
# (avoids H2D/D2H for a tiny int tensor); the main attention all-to-all stays on NCCL.
torch.distributed.init_process_group(backend="cpu:gloo,cuda:nccl")
transformer = getattr(pipeline, "transformer", None)
if transformer is None or not hasattr(transformer, "enable_parallelism"):
raise SystemExit(
"--context-parallel requires a DiT-based pipeline. "
f"{type(pipeline).__name__} does not expose a `transformer` with `enable_parallelism`."
)
from diffusers import ContextParallelConfig
transformer.enable_parallelism(
config=ContextParallelConfig(
ulysses_degree=torch.distributed.get_world_size(),
ring_degree=1,
ulysses_anything=True,
)
)
def _apply_optimizations(pipeline: Any, args: Namespace) -> None:
"""Apply VAE tiling/slicing, attention backend, context-parallel, and torch.compile toggles."""
vae = getattr(pipeline, "vae", None)
if args.vae_tiling and vae is not None and hasattr(vae, "enable_tiling"):
vae.enable_tiling()
if args.vae_slicing and vae is not None and hasattr(vae, "enable_slicing"):
vae.enable_slicing()
if args.attention_backend != "default":
_set_attention_backend(pipeline, args.attention_backend)
if args.context_parallel:
_enable_context_parallel(pipeline)
if args.compile is not None:
if args.context_parallel:
logger.warning("--compile is currently not supported with --context-parallel; skipping compile.")
else:
_compile_denoiser(pipeline, args.compile)
def _compile_denoiser(pipeline: Any, compile_spec: str) -> None:
"""Compile every `transformer*` and `unet*` submodule on the pipeline.
`compile_spec` is the raw JSON string from `--compile` (`"{}"` for bare flag). Decoded into kwargs and forwarded
verbatim to the compile call.
Prefers regional compilation via `module.compile_repeated_blocks(**kwargs)` — only compiles the repeated inner
blocks (the bulk of the compute), much faster first-step latency than compiling the whole module. Falls back to
full `torch.compile` if the model doesn't expose `_repeated_blocks`.
"""
import torch
try:
compile_kwargs = json.loads(compile_spec)
except json.JSONDecodeError as e:
raise SystemExit(f"--compile must be valid JSON: {e}") from e
if not isinstance(compile_kwargs, dict):
raise SystemExit("--compile must decode to a JSON object.")
for attr in dir(pipeline):
if not any(attr.startswith(key) for key in _DENOISER_COMPONENT_KEYS):
continue
module = getattr(pipeline, attr, None)
if not isinstance(module, torch.nn.Module):
continue
if getattr(module, "_repeated_blocks", None):
# Regional compile — only the repeated blocks. Mutates `module` in place.
module.compile_repeated_blocks(**compile_kwargs)
else:
# No regional metadata declared; fall back to compiling the whole module.
setattr(pipeline, attr, torch.compile(module, **compile_kwargs))
def _load_lora(pipeline: Any, args: Namespace) -> None:
"""Attach one or more LoRA adapters. Each `--lora` value is a JSON dict.
Per-entry fields: `lora_id` (required), `lora_scale` (optional float, default 1.0), `adapter_name` (optional;
auto-generated as `lora_<i>` when stacking). Multiple `--lora` flags stack via a single `set_adapters(...)` call at
the end.
"""
if not args.lora:
return
specs = []
for raw in args.lora:
try:
parsed = json.loads(raw)
except json.JSONDecodeError as e:
raise SystemExit(f"--lora must be valid JSON: {e}") from e
if not isinstance(parsed, dict):
raise SystemExit(f"--lora must decode to a JSON object; got {type(parsed).__name__}.")
specs.append(parsed)
if not hasattr(pipeline, "load_lora_weights"):
raise SystemExit(f"{type(pipeline).__name__} does not support LoRA loading.")
names: list[str] = []
scales: list[float] = []
for i, spec in enumerate(specs):
lora_id = spec.get("lora_id")
if not lora_id:
raise SystemExit(f"--lora entry {i} is missing 'lora_id'.")
adapter_name = spec.get("adapter_name") or (f"lora_{i}" if len(specs) > 1 else "default")
pipeline.load_lora_weights(lora_id, adapter_name=adapter_name)
names.append(adapter_name)
scales.append(float(spec.get("lora_scale", 1.0)))
if hasattr(pipeline, "set_adapters"):
pipeline.set_adapters(names, adapter_weights=scales)
def _load_pipeline(args: Namespace) -> Any:
import diffusers
# Detect modular repos by trying the standard config; `ModularPipeline` repos ship
# `modular_model_index.json` instead of `model_index.json`, so `load_config` OSErrors.
try:
diffusers.DiffusionPipeline.load_config(args.model, token=args.token, revision=args.revision)
modular = False
except OSError:
modular = True
dtype = _resolve_dtype(args.dtype)
device_map = _resolve_device_map(args.device_map)
common_kwargs: dict[str, Any] = {
"trust_remote_code": args.trust_remote_code,
}
if dtype != "auto":
common_kwargs["torch_dtype"] = dtype
if args.variant:
common_kwargs["variant"] = args.variant
if args.token:
common_kwargs["token"] = args.token
# CPU offload sets up its own placement hooks, so leave weights on CPU at load time.
if not args.cpu_offload:
common_kwargs["device_map"] = device_map
if modular:
# ModularPipeline.from_pretrained fetches only the pipeline config; component
# weights come in via load_components(). `revision` scopes the config fetch,
# so it stays on from_pretrained — each ComponentSpec pins its own revision,
# and forwarding a global `revision` to load_components() would override those.
pipeline = diffusers.ModularPipeline.from_pretrained(
args.model,
trust_remote_code=args.trust_remote_code,
token=args.token,
revision=args.revision,
)
pipeline.load_components(**common_kwargs)
else:
pipeline = diffusers.DiffusionPipeline.from_pretrained(args.model, revision=args.revision, **common_kwargs)
_load_lora(pipeline, args)
if args.cpu_offload:
_apply_cpu_offload(pipeline, args.cpu_offload, device_map)
_apply_optimizations(pipeline, args)
return pipeline
# ---------------------------------------------------------------------------
# Pipeline call helpers
# ---------------------------------------------------------------------------
def _parse_pipeline_kwargs(raw: str | None) -> dict[str, Any]:
if not raw:
return {}
try:
parsed = json.loads(raw)
except json.JSONDecodeError as e:
raise SystemExit(f"--pipeline-kwargs must be valid JSON: {e}") from e
if not isinstance(parsed, dict):
raise SystemExit("--pipeline-kwargs must decode to a JSON object.")
return parsed
def _load_audio(url_or_path: str) -> tuple[Any, int]:
"""Load audio from a URL or local path via torchaudio. Returns `(waveform, sampling_rate)`."""
import torchaudio
if url_or_path.startswith(("http://", "https://")):
import io
import httpx
from ..utils.constants import DIFFUSERS_REQUEST_TIMEOUT
resp = httpx.get(url_or_path, follow_redirects=True, timeout=DIFFUSERS_REQUEST_TIMEOUT)
resp.raise_for_status()
return torchaudio.load(io.BytesIO(resp.content))
return torchaudio.load(url_or_path)
def _resolve_media_inputs(call_kwargs: dict[str, Any]) -> None:
"""Replace string paths/URLs at known media-input keys with loaded tensors.
Images resolve to `PIL.Image.Image` via `load_image`; videos to `list[PIL.Image.Image]` via `load_video`; audio to
a `torch.Tensor` via `_load_audio` (also auto-sets the paired sampling-rate kwarg for `initial_audio_waveforms` if
the user didn't supply it). A `list[str]` at any key is treated as a batch: each entry is loaded and the value
becomes a list of loaded objects. Non-string, non-list values pass through untouched.
"""
def _is_string_list(v: Any) -> bool:
return isinstance(v, list) and bool(v) and all(isinstance(x, str) for x in v)
for key in _IMAGE_INPUT_KEYS:
value = call_kwargs.get(key)
if isinstance(value, str):
call_kwargs[key] = load_image(value)
elif _is_string_list(value):
call_kwargs[key] = [load_image(v) for v in value]
for key in _VIDEO_INPUT_KEYS:
value = call_kwargs.get(key)
if isinstance(value, str):
call_kwargs[key] = load_video(value)
elif _is_string_list(value):
call_kwargs[key] = [load_video(v) for v in value]
for key in _AUDIO_INPUT_KEYS:
value = call_kwargs.get(key)
if isinstance(value, str):
waveform, sr = _load_audio(value)
call_kwargs[key] = waveform
if key == "initial_audio_waveforms" and "initial_audio_sampling_rate" not in call_kwargs:
call_kwargs["initial_audio_sampling_rate"] = sr
elif _is_string_list(value):
pairs = [_load_audio(v) for v in value]
call_kwargs[key] = [w for w, _ in pairs]
if key == "initial_audio_waveforms" and "initial_audio_sampling_rate" not in call_kwargs:
# All batched waveforms must share a sampling rate; use the first entry's.
call_kwargs["initial_audio_sampling_rate"] = pairs[0][1]
def _get_generator(seed: int | None, device: str):
if seed is None:
return None
import torch
generator_device = "cpu" if device == "mps" else device
return torch.Generator(device=generator_device).manual_seed(seed)
def _unwrap_pipeline_output(result: Any) -> Any:
"""Unwrap a pipeline-output object into the raw payload the saver can dispatch on."""
if hasattr(result, "images"):
return result.images
if hasattr(result, "frames"):
return result.frames[0]
if hasattr(result, "audios"):
return result.audios
return result
# ---------------------------------------------------------------------------
# Output saving (dispatch by type)
# ---------------------------------------------------------------------------
def _get_or_create_run_id() -> str:
"""Return the current run's id, creating one if not yet set.
Format: `diffusers-run-<YYYYMMDDTHHMMSS>-<6-char-uuid>`. Same id is reused as the local output subdirectory, the
remote bucket prefix, and the container-side `RUN_ID_ENV` so a run's artifacts are traceable end-to-end.
"""
import uuid
from datetime import datetime
existing = os.environ.get(RUN_ID_ENV)
if existing:
return existing
run_id = f"diffusers-run-{datetime.now().strftime('%Y%m%dT%H%M%S')}-{uuid.uuid4().hex[:6]}"
os.environ[RUN_ID_ENV] = run_id
return run_id
def _resolve_output_paths(task: str, num: int, explicit: str | None, ext: str) -> list[Path]:
if explicit is None:
base = Path(DEFAULT_OUTPUT_DIR) / _get_or_create_run_id()
base.mkdir(parents=True, exist_ok=True)
return [base / f"{i:04d}.{ext}" for i in range(num)]
p = Path(explicit)
if explicit.endswith(os.sep) or p.is_dir():
p.mkdir(parents=True, exist_ok=True)
return [p / f"{i:04d}.{ext}" for i in range(num)]
p.parent.mkdir(parents=True, exist_ok=True)
if num == 1:
return [p]
stem, suffix = p.stem, p.suffix or f".{ext}"
return [p.with_name(f"{stem}-{i:04d}{suffix}") for i in range(num)]
def _as_pil_list(value: Any):
try:
from PIL.Image import Image as PILImage
except ImportError:
return None
if isinstance(value, PILImage):
return [value]
if isinstance(value, (list, tuple)) and value and all(isinstance(v, PILImage) for v in value):
return list(value)
return None
def _as_frame_sequence(value: Any):
try:
from PIL.Image import Image as PILImage
except ImportError:
PILImage = None # type: ignore[assignment]
if isinstance(value, (list, tuple)) and len(value) >= 2:
first = value[0]
if PILImage is not None and isinstance(first, PILImage):
return list(value)
try:
import numpy as np
if isinstance(first, np.ndarray):
return list(value)
except ImportError:
pass
return None
def _as_audio_arrays(value: Any):
try:
import numpy as np
except ImportError:
return None
if isinstance(value, np.ndarray) and value.ndim <= 2:
return [value]
if isinstance(value, (list, tuple)) and value and all(isinstance(v, np.ndarray) for v in value):
return list(value)
return None
def _save_audio_arrays(audios, sampling_rate: int, args: Namespace, task: str) -> list[str]:
"""Write each numpy audio array to a 16-bit PCM WAV at `sampling_rate` Hz.
Uses the stdlib `wave` module so no scipy dependency is required.
"""
import wave
import numpy as np
paths = _resolve_output_paths(task, len(audios), args.output, ext="wav")
saved: list[str] = []
for audio, path in zip(audios, paths):
data = np.asarray(audio)
if data.dtype.kind == "f":
data = (np.clip(data, -1.0, 1.0) * 32767).astype(np.int16)
else:
data = data.astype(np.int16)
if data.ndim == 1:
n_channels = 1
else:
# Heuristic: shorter axis is channels (interleaved layout for `wave` is
# samples × channels, so transpose if needed).
if data.shape[0] < data.shape[-1]:
data = data.T
n_channels = data.shape[1]
with wave.open(str(path), "wb") as w:
w.setnchannels(n_channels)
w.setsampwidth(2) # 16-bit PCM
w.setframerate(sampling_rate)
w.writeframes(data.tobytes())
saved.append(str(path))
return saved
def _save_output(value: Any, args: Namespace, task: str) -> list[str]:
"""Save `value` by dispatching on its runtime type."""
pil_images = _as_pil_list(value)
if pil_images is not None:
paths = _resolve_output_paths(task, len(pil_images), args.output, ext="png")
for img, path in zip(pil_images, paths):
img.save(path)
return [str(p) for p in paths]
frames = _as_frame_sequence(value)
if frames is not None:
from diffusers.utils import export_to_video
path = _resolve_output_paths(task, 1, args.output, ext="mp4")[0]
export_to_video(frames, str(path), fps=args.fps)
return [str(path)]
audios = _as_audio_arrays(value)
if audios is not None:
return _save_audio_arrays(audios, args.sampling_rate or 16000, args, task)
path = _resolve_output_paths(task, 1, args.output, ext="json")[0]
Path(path).write_text(json.dumps(value, default=str, indent=2))
return [str(path)]
# ---------------------------------------------------------------------------
# Hub bucket upload (--push-to)
# ---------------------------------------------------------------------------
def _parse_push_to(spec: str) -> tuple[str, str]:
"""Split `--push-to` into a bucket id and an optional subpath prefix.
Accepts an HF bucket id (`<namespace>/<name>[/<subpath>]`), a canonical
`hf://buckets/<namespace>/<name>[/<subpath>]` URI, or a Hub web URL for the same. Non-bucket URIs (models,
datasets, spaces) are rejected — `--push-to` targets storage buckets only.
"""
from huggingface_hub import parse_hf_uri
# Bare shorthand → canonical URI so a single parser handles every accepted form.
if not spec.startswith(("hf://", "http://", "https://")):
spec = f"hf://buckets/{spec.strip('/')}"
uri = parse_hf_uri(spec)
if not uri.is_bucket:
raise SystemExit(f"--push-to must point at a bucket; got {uri.type!r} URI {spec!r}.")
return uri.id, uri.path_in_repo
def _push_outputs(args: Namespace, saved_paths: list[str], task: str) -> dict[str, Any] | None:
"""Upload `saved_paths` to the `--push-to` bucket. Returns a summary or None."""
if not args.push_to:
return None
from huggingface_hub import HfApi
bucket_id, subpath = _parse_push_to(args.push_to)
api = HfApi(token=args.token)
api.create_bucket(bucket_id, exist_ok=True)
run_id = _get_or_create_run_id()
prefix = f"{subpath}/{run_id}" if subpath else run_id
add = [(local, f"{prefix}/{Path(local).name}") for local in saved_paths]
api.batch_bucket_files(bucket_id, add=add)
uploaded = [f"hf://buckets/{bucket_id}/{dest}" for _, dest in add]
return {"bucket_id": bucket_id, "uploaded": uploaded}
# ---------------------------------------------------------------------------
# Remote execution (HF Sandbox)
# ---------------------------------------------------------------------------
def _build_task_kwargs(args: Namespace) -> dict[str, Any]:
"""Pick out the kwargs the sandbox CLI should invoke the task with."""
out: dict[str, Any] = {}
for key, value in vars(args).items():
if key in REMOTE_KEYS or value is None or value is False:
continue
out[key] = value
return out
def _kwargs_to_argv(task: str, task_kwargs: dict[str, Any]) -> list[str]:
"""Render `task_kwargs` as the argv list the sandbox CLI's argparse will see."""
argv: list[str] = [task]
for key, value in task_kwargs.items():
flag = "--" + key.replace("_", "-")
if value is True:
argv.append(flag)
elif isinstance(value, list):
for item in value:
argv.extend([flag, str(item)])
else:
argv.extend([flag, str(value)])
return argv
def _duration_to_seconds(value: str) -> float:
"""Parse a duration like `30s`, `10m`, `2h` (or a bare number of seconds) into seconds."""
value = value.strip()
units = {"s": 1, "m": 60, "h": 3600}
if value and value[-1] in units:
return float(value[:-1]) * units[value[-1]]
return float(value)
def _upload_inputs_to_sandbox(args: Namespace, sbx: Any, run_id: str) -> None:
"""Upload local media paths in `--pipeline-kwargs` into the sandbox and rewrite the JSON in place.
Walks known image/video/audio-input keys; any string value that resolves to a local file is uploaded to
`<_SANDBOX_INPUTS_DIR>/<run_id>/<key>_<basename>` and the JSON path is rewritten to that in-sandbox path. URLs,
`hf://` URIs, and non-existent paths pass through untouched.
"""
if not args.pipeline_kwargs:
return
try:
parsed = json.loads(args.pipeline_kwargs)
except json.JSONDecodeError:
return # the sandbox CLI will fail loudly with a parse error later
if not isinstance(parsed, dict):
return
def _upload_one(key: str, index: int | None, local_str: str) -> str:
# `index` is None for scalar entries, an int for list entries (used to disambiguate names).
local = Path(local_str)
suffix = f"_{index}" if index is not None else ""
remote_path = f"{_SANDBOX_INPUTS_DIR}/{run_id}/{key}{suffix}_{local.name}"
sbx.files.upload(str(local), remote_path)
return remote_path
uploaded = 0
for key in (*_IMAGE_INPUT_KEYS, *_VIDEO_INPUT_KEYS, *_AUDIO_INPUT_KEYS):
value = parsed.get(key)
if isinstance(value, str) and Path(value).is_file():
parsed[key] = _upload_one(key, None, value)
uploaded += 1
elif isinstance(value, list):
# Batched inputs: upload each local path, leave URLs/hf:// URIs alone.
new_list = list(value)
for i, entry in enumerate(value):
if isinstance(entry, str) and Path(entry).is_file():
new_list[i] = _upload_one(key, i, entry)
uploaded += 1
parsed[key] = new_list
if uploaded:
logger.info(f"uploaded {uploaded} local input file(s) to the sandbox")
args.pipeline_kwargs = json.dumps(parsed)
def _download_outputs_from_sandbox(sbx: Any, sandbox_dir: str, local_dir: Path) -> list[str]:
"""Download every file the sandbox CLI wrote under `sandbox_dir` into `local_dir`."""
local_dir.mkdir(parents=True, exist_ok=True)
saved: list[str] = []
for entry in sbx.files.list(sandbox_dir):
if entry.type != "file":
continue
target = local_dir / Path(entry.path).name
sbx.files.download(entry.path, str(target))
saved.append(str(target))
return saved
def _maybe_submit_remote(args: Namespace, task: str) -> bool:
"""If `--remote` was set, run this invocation inside an HF Sandbox and return True."""
if not args.remote:
return False
import shlex
import time
from huggingface_hub import get_token
from huggingface_hub.utils import send_telemetry
import diffusers
try:
from huggingface_hub import Sandbox
except ImportError:
raise SystemExit(
"--remote requires huggingface_hub>=1.23 for HF Sandbox support. "
"Upgrade with `pip install -U huggingface_hub`."
)
if Path(args.model).exists():
raise SystemExit(
f"--model {args.model!r} is a local path; the sandbox can't see it. "
"Pass a Hub repo id so the sandbox can download it."
)
hf_token = args.token or get_token()
run_id = _get_or_create_run_id()
# An explicit --push-to means the bucket is the user's destination, so skip the local
# download unless they also asked for a local path via --output.
user_bucket = bool(args.push_to)
download_locally = (not user_bucket) or (args.output is not None)
local_dir = Path(args.output) if args.output else Path(DEFAULT_OUTPUT_DIR) / run_id
use_existing_sandbox = bool(args.sandbox_id)
keep_alive = args.keep_alive or use_existing_sandbox
if use_existing_sandbox and args.volume:
logger.warning(
"--volume is ignored when reconnecting to an existing sandbox (mounts are set at creation time)."
)
if use_existing_sandbox:
logger.info(f"reconnecting to sandbox {args.sandbox_id!r}...")
sbx = Sandbox.connect(args.sandbox_id, token=hf_token)
else:
logger.info(f"creating sandbox on flavor={args.flavor!r}...")
create_kwargs: dict[str, Any] = {
"image": args.image or _DEFAULT_REMOTE_IMAGE,
"flavor": args.flavor,
"forward_hf_token": True,
"token": hf_token,
"env": {
"HF_ENABLE_PARALLEL_LOADING": "1",
"DIFFUSERS_VERBOSITY": os.environ.get("DIFFUSERS_VERBOSITY", "info"),
},
"idle_timeout": args.idle_timeout,
}
if args.volume:
from huggingface_hub import Volume
volumes = []
for spec in args.volume:
bucket_id, sep, mount_path = spec.partition(":")
if not sep:
mount_path = f"/mnt/buckets/{bucket_id}"
if bucket_id.count("/") != 1:
raise SystemExit(f"--volume: bucket id must be <namespace>/<name>, got {bucket_id!r}")
if not mount_path.startswith("/"):
raise SystemExit(f"--volume: mount path must be absolute, got {mount_path!r}")
volumes.append(Volume(type="bucket", source=bucket_id, mount_path=mount_path))
create_kwargs["volumes"] = volumes
if args.namespace is not None:
create_kwargs["namespace"] = args.namespace
sbx = Sandbox.create(**create_kwargs)
def _stream(chunk: str) -> None:
sys.stderr.write(chunk)
sys.stderr.flush()
exit_code = 0
saved: list[str] = []
run_seconds = 0.0
try:
_upload_inputs_to_sandbox(args, sbx, run_id)
dependencies = list(_DEFAULT_REMOTE_DEPS)
if args.dependencies:
dependencies.extend(args.dependencies)
# --break-system-packages bypasses PEP 668; harmless in a throwaway sandbox. uv is a
# near no-op when the deps are already satisfied, so this stays cheap on a reused sandbox.
install_cmd = shlex.join(["uv", "pip", "install", "--system", "--break-system-packages", *dependencies])
logger.info("installing dependencies in the sandbox...")
sbx.run(install_cmd, on_stdout=_stream, on_stderr=_stream)
# Per-run outputs subdirectory so a reused sandbox doesn't leak files from prior runs
# into this run's download set.
sandbox_output_dir = f"{_SANDBOX_OUTPUTS_DIR}/{run_id}"
task_kwargs = _build_task_kwargs(args)
task_kwargs["output"] = sandbox_output_dir + "/"
cli_argv = _kwargs_to_argv(task, task_kwargs)
# Suppress the container CLI's own `out.result(...)` payload — the outer wrapper owns the
# final structured output for --remote runs.
format_argv = ["--format", "quiet"]
# torchrun wraps the CLI for --context-parallel so torch.distributed initializes across
# every visible GPU before the run command starts.
if args.context_parallel:
cli_argv = [
"torchrun",
"--nproc-per-node=gpu",
"-m",
"diffusers.commands.diffusers_cli",
*format_argv,
*cli_argv,
]
else:
cli_argv = [_CONTAINER_CLI_BINARY, *format_argv, *cli_argv]
started = time.perf_counter()
# Per-invocation env: RUN_ID_ENV must be fresh each run. Sandbox.create-time env is
# baked in and would go stale on reused sandboxes, silently reusing the initial run's
# bucket prefix in `_push_outputs`.
result = sbx.run(
cli_argv,
env={RUN_ID_ENV: run_id},
on_stdout=_stream,
on_stderr=_stream,
timeout=_duration_to_seconds(args.timeout),
check=False,
)
run_seconds = time.perf_counter() - started
exit_code = result.exit_code
if exit_code == 0 and download_locally:
saved = _download_outputs_from_sandbox(sbx, sandbox_output_dir, local_dir)
finally:
if keep_alive:
logger.info(
f"sandbox {sbx.id} kept alive — reconnect with "
f"`--remote --sandbox-id {sbx.id}`, stop with `hf sandbox kill {sbx.id}`."
)
else:
sbx.kill()
send_telemetry(
topic="diffusers/cli/run/remote",
library_name="diffusers",
library_version=diffusers.__version__,
)
payload: dict[str, Any] = {
"exit_code": exit_code,
"run_seconds": round(run_seconds, 1),
}
if keep_alive:
payload["sandbox_id"] = sbx.id
if download_locally:
payload["outputs"] = saved
if args.push_to:
bucket_id, subpath = _parse_push_to(args.push_to)
prefix = f"{subpath}/{run_id}" if subpath else run_id
payload["pushed-to"] = f"hf://buckets/{bucket_id}/{prefix}/"
out.result("remote-run", **payload)
if exit_code != 0:
raise SystemExit(f"remote run failed with exit code {exit_code}")
return True
# ---------------------------------------------------------------------------
# Subcommand
# ---------------------------------------------------------------------------
class RunCommand(BaseDiffusersCLICommand):
task = "run"
@staticmethod
def register_subcommand(subparsers: _SubParsersAction) -> None:
from argparse import RawDescriptionHelpFormatter
epilog = (
"Examples\n"
" $ diffusers-cli run -m black-forest-labs/FLUX.1-dev --dtype bf16 \\\n"
' --pipeline-kwargs \'{"prompt": "a cat on the moon"}\'\n'
" $ diffusers-cli run -m black-forest-labs/FLUX.1-dev --dtype bf16 \\\n"
' --pipeline-kwargs \'{"prompt": "make the fur grey", "image": "https://huggingface.co/datasets/huggingface/documentation-images/resolve/main/diffusers/cat.png"}\'\n'
" $ diffusers-cli run -m black-forest-labs/FLUX.1-dev --dtype bf16 \\\n"
' --pipeline-kwargs \'{"prompt": "a tiny cat"}\' \\\n'
' --lora \'{"lora_id": "alvdansen/littletinies", "lora_scale": 0.8}\'\n'
" $ diffusers-cli run -m black-forest-labs/FLUX.1-dev --dtype bf16 \\\n"
' --pipeline-kwargs \'{"prompt": "a cat"}\' --remote --flavor a100-large\n'
" $ diffusers-cli run -m black-forest-labs/FLUX.1-dev --dtype bf16 --context-parallel \\\n"
' --pipeline-kwargs \'{"prompt": "a cat"}\' --remote --flavor 4xa100-large\n'
"\n"
"Learn more\n"
" Use `diffusers-cli <command> --help` for more information about a command.\n"
" Read the documentation at https://huggingface.co/docs/diffusers\n"
)
parser: ArgumentParser = subparsers.add_parser(
"run",
help="Run any diffusers pipeline locally or remotely in an HF Sandbox.",
usage="\n diffusers-cli run [options]",
epilog=epilog,
formatter_class=RawDescriptionHelpFormatter,
)
parser._optionals.title = "Options"
_add_loading_arguments(parser)
_add_optimization_arguments(parser)
parser.add_argument(
"--pipeline-kwargs",
default=None,
help=(
"JSON object of kwargs passed to the pipeline call. String values at known "
f"image-input keys ({', '.join(_IMAGE_INPUT_KEYS)}) are auto-loaded as PIL images; "
f"video-input keys ({', '.join(_VIDEO_INPUT_KEYS)}) are auto-loaded as frame lists; "
f"audio-input keys ({', '.join(_AUDIO_INPUT_KEYS)}) are auto-loaded via torchaudio."
),
)
parser.add_argument(
"--output-key",
default=None,
help="For modular pipelines: name of the intermediate to extract (passed as `output=` to the call).",
)
parser.add_argument("--seed", type=int, default=None, help="Random seed for reproducibility.")
parser.add_argument(
"--fps",
type=int,
default=8,
help="FPS used when the output happens to be a frame sequence.",
)
parser.add_argument(
"--sampling-rate",
type=int,
default=None,
help="Sample rate used when the output happens to be an audio array.",
)
_add_remote_arguments(parser)
_add_output_arguments(parser)
parser.set_defaults(func=RunCommand)
def __init__(self, args: Namespace):
self.args = args
def run(self) -> None:
import diffusers
_get_or_create_run_id() # populate RUN_ID_ENV so local output dir + remote bucket prefix agree
call_kwargs = _parse_pipeline_kwargs(self.args.pipeline_kwargs)
if _maybe_submit_remote(self.args, self.task):
return
# Resolve media before loading pipeline weights so dead URLs / missing files fail
# fast — cheap to fetch, expensive to load a 20GB model just to hit a 404.
_resolve_media_inputs(call_kwargs)
pipeline = _load_pipeline(self.args)
is_modular = isinstance(pipeline, diffusers.ModularPipeline)
if self.args.output_key is not None:
call_kwargs["output"] = self.args.output_key
device = pipeline.device.type if hasattr(pipeline, "device") else "cpu"
generator = _get_generator(self.args.seed, device)
if generator is not None:
call_kwargs["generator"] = generator
try:
result = pipeline(**call_kwargs)
# Under torchrun, ranks > 0 produce identical output to rank 0 (CP shards the
# transformer compute but ranks reduce to the same final tensors). Save/push/print
# from rank 0 only to avoid clobbering bucket files 4x and printing 4x.
if os.environ.get("RANK", "0") == "0":
savable = result if is_modular else _unwrap_pipeline_output(result)
saved = _save_output(savable, self.args, self.task)
pushed = _push_outputs(self.args, saved, self.task)
out.result(
self.task,
model=self.args.model,
device=device,
pipeline_class=type(pipeline).__name__,
modular=is_modular,
outputs=saved,
pushed=pushed,
seed=self.args.seed,
output_key=self.args.output_key,
)
finally:
import torch
if torch.distributed.is_available() and torch.distributed.is_initialized():
torch.distributed.destroy_process_group()