From d0c72b25a15bf7518f9c729ecd02a64231c0aa39 Mon Sep 17 00:00:00 2001 From: DN6 Date: Tue, 4 Aug 2026 00:21:47 +0530 Subject: [PATCH 01/24] update --- src/diffusers/commands/custom_blocks.py | 7 +- src/diffusers/commands/run.py | 263 +++++++++++++++--------- tests/others/test_cli_commands.py | 89 ++++++++ 3 files changed, 258 insertions(+), 101 deletions(-) diff --git a/src/diffusers/commands/custom_blocks.py b/src/diffusers/commands/custom_blocks.py index 7ebaf785ba48..a3649117e002 100644 --- a/src/diffusers/commands/custom_blocks.py +++ b/src/diffusers/commands/custom_blocks.py @@ -103,7 +103,12 @@ def run(self): spec = importlib.util.spec_from_file_location(module_name, str(self.block_module_name)) module = importlib.util.module_from_spec(spec) spec.loader.exec_module(module) - getattr(module, child_class)().save_pretrained(os.getcwd()) + block = getattr(module, child_class)() + block.save_pretrained(os.getcwd()) + # `ModularPipeline.from_pretrained` (and therefore `diffusers-cli run`) loads a repo + # through `modular_model_index.json`, which only the pipeline-level save writes — without + # it the packaged repo is importable as blocks but not runnable as a pipeline. + block.init_pipeline().save_pretrained(os.getcwd()) def _choose_block(self, candidates, chosen=None): for cls, base in candidates: diff --git a/src/diffusers/commands/run.py b/src/diffusers/commands/run.py index 9cd638547834..bf06e1a714a2 100644 --- a/src/diffusers/commands/run.py +++ b/src/diffusers/commands/run.py @@ -20,17 +20,34 @@ from __future__ import annotations +import inspect +import io import json import os +import shlex import sys -from argparse import ArgumentParser, Namespace, _SubParsersAction +import time +import uuid +import wave +from argparse import ArgumentParser, Namespace, RawDescriptionHelpFormatter, _SubParsersAction +from datetime import datetime from pathlib import Path from typing import Any +import httpx +import numpy as np +import torch +from huggingface_hub import HfApi, Sandbox, Volume, get_token, parse_hf_uri from huggingface_hub.cli._output import out +from huggingface_hub.utils import send_telemetry +from PIL import Image +import diffusers +from diffusers import ContextParallelConfig from diffusers.models.attention_dispatch import _HUB_KERNELS_REGISTRY -from diffusers.utils import load_image, load_video, logging +from diffusers.utils import export_to_video, load_image, load_video, logging +from diffusers.utils.constants import DIFFUSERS_REQUEST_TIMEOUT +from diffusers.utils.torch_utils import torch_device from . import BaseDiffusersCLICommand @@ -44,7 +61,7 @@ DEFAULT_OUTPUT_DIR = str(Path.home() / ".diffusers" / "cli" / "run" / "outputs") DTYPE_CHOICES = ("auto", "float16", "fp16", "bfloat16", "bf16", "float32", "fp32") -CPU_OFFLOAD_CHOICES = ("model", "group") +CPU_OFFLOAD_CHOICES = ("model", "group", "auto") ATTENTION_BACKEND_CHOICES = ("default", *sorted(b.value for b in _HUB_KERNELS_REGISTRY)) @@ -80,6 +97,8 @@ "safetensors", "sentencepiece", # required by several text-encoder tokenizers (T5, LLaMA, …) "ftfy", # required by older CLIP text-encoder paths + "imageio", # preferred `export_to_video` backend + "imageio-ffmpeg", # bundles a static ffmpeg; the cv2 fallback needs system libs the slim image lacks ) # Base sandbox image — provides torch + CUDA so `uv pip install --system` @@ -160,7 +179,9 @@ def _add_optimization_arguments(parser: ArgumentParser) -> 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)." + "'group' uses pipeline.enable_group_offload(leaf_level, use_stream=True). " + "Modular pipelines only support 'auto', which offloads through a ComponentsManager " + "via enable_auto_cpu_offload." ), ) parser.add_argument( @@ -305,7 +326,6 @@ def _add_remote_arguments(parser: ArgumentParser) -> None: def _resolve_dtype(name: str | None): if name in (None, "auto"): return "auto" - import torch mapping = { "fp32": torch.float32, @@ -327,13 +347,9 @@ def _resolve_device_map(raw: str | None) -> str | dict: `"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 @@ -351,18 +367,29 @@ def _resolve_device_map(raw: str | None) -> str | dict: 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).""" + """Apply CPU offload. Requires a single-device target (not balanced or dict). + + Standard pipelines support 'model' and 'group'; modular pipelines offload through the ComponentsManager they were + loaded with ('auto'). + """ 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 isinstance(pipeline, diffusers.ModularPipeline): + pipeline._components_manager.enable_auto_cpu_offload(device=device_map) + return + + if mode == "auto": + raise SystemExit( + "--cpu-offload auto only applies to modular pipelines (it offloads through a " + "ComponentsManager). Use 'model' or 'group' for standard pipelines." + ) 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", @@ -388,8 +415,6 @@ def _set_attention_backend(pipeline: Any, backend: str) -> None: 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.") @@ -405,8 +430,6 @@ def _enable_context_parallel(pipeline: Any) -> None: 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(), @@ -444,7 +467,6 @@ def _compile_denoiser(pipeline: Any, compile_spec: str) -> None: 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) @@ -505,8 +527,6 @@ def _load_lora(pipeline: Any, args: Namespace) -> None: 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: @@ -531,6 +551,12 @@ def _load_pipeline(args: Namespace) -> Any: common_kwargs["device_map"] = device_map if modular: + if args.cpu_offload and args.cpu_offload != "auto": + raise SystemExit( + f"--cpu-offload {args.cpu_offload!r} is not supported for modular pipelines — they " + "offload through a ComponentsManager. Use `--cpu-offload auto`." + ) + components_manager = diffusers.ComponentsManager() if args.cpu_offload else None # 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, @@ -540,6 +566,7 @@ def _load_pipeline(args: Namespace) -> Any: trust_remote_code=args.trust_remote_code, token=args.token, revision=args.revision, + components_manager=components_manager, ) pipeline.load_components(**common_kwargs) else: @@ -575,12 +602,6 @@ def _load_audio(url_or_path: str) -> tuple[Any, int]: 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)) @@ -629,7 +650,6 @@ def _is_string_list(v: Any) -> bool: 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) @@ -657,8 +677,6 @@ def _get_or_create_run_id() -> str: Format: `diffusers-run--<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: @@ -668,7 +686,7 @@ def _get_or_create_run_id() -> str: return run_id -def _resolve_output_paths(task: str, num: int, explicit: str | None, ext: str) -> list[Path]: +def _resolve_output_paths(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) @@ -687,42 +705,20 @@ def _resolve_output_paths(task: str, num: int, explicit: str | None, ext: str) - def _as_pil_list(value: Any): - try: - from PIL.Image import Image as PILImage - except ImportError: - return None - if isinstance(value, PILImage): + if isinstance(value, Image.Image): return [value] - if isinstance(value, (list, tuple)) and value and all(isinstance(v, PILImage) for v in value): + if isinstance(value, (list, tuple)) and value and all(isinstance(v, Image.Image) 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 + if isinstance(value, (list, tuple)) and len(value) >= 2 and isinstance(value[0], (Image.Image, np.ndarray)): + return list(value) 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): @@ -730,16 +726,13 @@ def _as_audio_arrays(value: Any): return None -def _save_audio_arrays(audios, sampling_rate: int, args: Namespace, task: str) -> list[str]: +def _save_audio_arrays(audios, sampling_rate: int, args: Namespace) -> 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") + paths = _resolve_output_paths(len(audios), args.output, ext="wav") saved: list[str] = [] for audio, path in zip(audios, paths): data = np.asarray(audio) @@ -764,29 +757,108 @@ def _save_audio_arrays(audios, sampling_rate: int, args: Namespace, task: str) - return saved -def _save_output(value: Any, args: Namespace, task: str) -> list[str]: - """Save `value` by dispatching on its runtime type.""" +def _warn_missing_video_export_backend() -> None: + """Warn before pipeline load if video output could not be written as mp4. + + Runs pre-flight (like early media resolution) both locally and inside the `--remote` sandbox, so a broken backend + surfaces before minutes of download and inference rather than at save time. A warning, not an error: image/audio + outputs don't need the backend, and `_save_videos` falls back to `.pt` frame dumps. + """ + try: + import imageio # noqa: F401 + import imageio_ffmpeg # noqa: F401 + + return + except ImportError: + pass + try: + # cv2 raises OSError (not ImportError) when its native system libraries are missing. + import cv2 # noqa: F401 + + return + except Exception: + pass + logger.warning( + "No working video export backend found — if this pipeline outputs video, raw frames will " + "be saved as `.pt` tensors instead of mp4. Install one with: pip install imageio imageio-ffmpeg" + ) + + +def _save_videos(videos: list[Any], args: Namespace) -> list[str]: + """Write each frame sequence to mp4, one file per video. + + The frames took real GPU time to produce, so a missing or broken export backend must never discard them: on export + failure each video's raw frames are saved as a `.pt` tensor instead and a warning tells the user how to export + them. + """ + mp4_paths = _resolve_output_paths(len(videos), args.output, ext="mp4") + try: + for frames, path in zip(videos, mp4_paths): + export_to_video(list(frames), str(path), fps=args.fps) + return [str(p) for p in mp4_paths] + except Exception as e: + pt_paths = _resolve_output_paths(len(videos), args.output, ext="pt") + for frames, path in zip(videos, pt_paths): + torch.save(torch.as_tensor(np.stack([np.asarray(frame) for frame in frames])), path) + logger.warning( + f"Video export failed ({e}); saved the raw frames of {len(videos)} video(s) as " + f"(num_frames, H, W, C) `.pt` tensors under {Path(pt_paths[0]).parent} instead — " + "nothing was discarded. Load with `torch.load` and write with " + "`diffusers.utils.export_to_video` once `imageio` and `imageio-ffmpeg` are installed." + ) + return [str(p) for p in pt_paths] + + +def _save_output(value: Any, args: Namespace) -> list[str]: + """Save `value` by dispatching on its runtime type and, for arrays, its shape.""" + # `run` asks pipelines for `output_type="pt"`; tensor outputs are channels-first + # ((B, C, H, W) images, (B, C, F, H, W) video), while the array branches below expect + # channels-last — convert here so one set of shape branches handles both. + if isinstance(value, torch.Tensor): + arr = value.detach().to(torch.float32).cpu().numpy() + if arr.ndim == 5: + arr = arr.transpose(0, 2, 3, 4, 1) + elif arr.ndim == 4: + arr = arr.transpose(0, 2, 3, 1) + value = arr + + # Array shapes are unambiguous where PIL lists are not: (B, F, H, W, C) is batched video, + # (B, H, W, C) is batched images. + if isinstance(value, np.ndarray): + if value.ndim == 5: + return _save_videos(list(value), args) + if value.ndim == 4: + paths = _resolve_output_paths(len(value), args.output, ext="png") + for arr, path in zip(value, paths): + if arr.dtype != np.uint8: + arr = (np.clip(arr, 0.0, 1.0) * 255).round().astype(np.uint8) + Image.fromarray(arr).save(path) + return [str(p) for p in paths] + pil_images = _as_pil_list(value) if pil_images is not None: - paths = _resolve_output_paths(task, len(pil_images), args.output, ext="png") + paths = _resolve_output_paths(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 + return _save_videos([frames], args) - path = _resolve_output_paths(task, 1, args.output, ext="mp4")[0] - export_to_video(frames, str(path), fps=args.fps) - return [str(path)] + # A batch of PIL frame sequences — what video pipelines return for an explicit + # `output_type="pil"`. Previously this matched no branch and fell through to the JSON dump. + if isinstance(value, (list, tuple)) and value and all(_as_frame_sequence(v) is not None for v in value): + return _save_videos([list(v) for v in value], args) audios = _as_audio_arrays(value) if audios is not None: - return _save_audio_arrays(audios, args.sampling_rate or 16000, args, task) + return _save_audio_arrays(audios, args.sampling_rate or 16000, args) - path = _resolve_output_paths(task, 1, args.output, ext="json")[0] - Path(path).write_text(json.dumps(value, default=str, indent=2)) + # Anything that isn't a recognized media shape is saved as a torch tensor: `torch.save` + # handles tensors natively and pickles everything else, so no output is ever dropped. + path = _resolve_output_paths(1, args.output, ext="pt")[0] + torch.save(value, path) return [str(path)] @@ -802,7 +874,6 @@ def _parse_push_to(spec: str) -> tuple[str, str]: `hf://buckets//[/]` 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://")): @@ -813,13 +884,11 @@ def _parse_push_to(spec: str) -> tuple[str, str]: return uri.id, uri.path_in_repo -def _push_outputs(args: Namespace, saved_paths: list[str], task: str) -> dict[str, Any] | None: +def _push_outputs(args: Namespace, saved_paths: list[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) @@ -934,22 +1003,6 @@ def _maybe_submit_remote(args: Namespace, task: str) -> bool: 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. " @@ -988,8 +1041,6 @@ def _maybe_submit_remote(args: Namespace, task: str) -> bool: "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(":") @@ -1108,8 +1159,6 @@ class RunCommand(BaseDiffusersCLICommand): @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" @@ -1175,8 +1224,6 @@ 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) @@ -1187,9 +1234,17 @@ def run(self) -> None: # 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) + _warn_missing_video_export_backend() pipeline = _load_pipeline(self.args) is_modular = isinstance(pipeline, diffusers.ModularPipeline) + # Ask for tensors instead of PIL: the shape then tells `_save_output` exactly what the + # output is — a flat PIL list can't distinguish one video from a batch of images. An + # explicit user `output_type` always wins. + if not is_modular and "output_type" not in call_kwargs: + if "output_type" in inspect.signature(pipeline.__call__).parameters: + call_kwargs["output_type"] = "pt" + if self.args.output_key is not None: call_kwargs["output"] = self.args.output_key @@ -1206,8 +1261,18 @@ def run(self) -> None: # 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) + try: + saved = _save_output(savable, self.args) + except Exception as e: + # The output took real GPU time to produce — never let a save failure + # discard it. torch.save handles tensors natively and pickles everything + # else (ndarrays, PIL images, lists). + + path = _resolve_output_paths(1, self.args.output, ext="pt")[0] + torch.save(savable, path) + logger.warning(f"Saving the output failed ({e}); saving pipeline output tensors to {path} ") + saved = [str(path)] + pushed = _push_outputs(self.args, saved) out.result( self.task, @@ -1221,7 +1286,5 @@ def run(self) -> None: output_key=self.args.output_key, ) finally: - import torch - if torch.distributed.is_available() and torch.distributed.is_initialized(): torch.distributed.destroy_process_group() diff --git a/tests/others/test_cli_commands.py b/tests/others/test_cli_commands.py index cdbf6dd4090c..08bc63b452f6 100644 --- a/tests/others/test_cli_commands.py +++ b/tests/others/test_cli_commands.py @@ -16,8 +16,10 @@ One test per contract that would ship broken if regressed. Grouped by command. """ +import os import subprocess from argparse import ArgumentParser, Namespace +from pathlib import Path import pytest @@ -29,6 +31,7 @@ _parse_pipeline_kwargs, _resolve_dtype, _resolve_media_inputs, + _save_output, _upload_inputs_to_sandbox, ) from diffusers.commands.schema import _parse_docstring_args @@ -247,6 +250,76 @@ def test_attention_backend_arg(self): } assert backends == {AttentionBackendName.FLASH_HUB} + def test_save_output_ndarray_video_batch(self, tmp_path, monkeypatch): + # (B, F, H, W, C) arrays are batched video: one mp4 per batch item. + import numpy as np + + exported: list[tuple[int, str]] = [] + monkeypatch.setattr( + "diffusers.commands.run.export_to_video", + lambda frames, path, fps: exported.append((len(frames), path)), + ) + args = Namespace(output=str(tmp_path) + os.sep, fps=24, sampling_rate=None) + saved = _save_output(np.zeros((2, 3, 8, 8, 3), dtype=np.float32), args) + assert [Path(p).suffix for p in saved] == [".mp4", ".mp4"] + assert [n for n, _ in exported] == [3, 3] + + def test_save_output_ndarray_image_batch(self, tmp_path): + # (B, H, W, C) arrays are batched images: one png per item. + import numpy as np + + args = Namespace(output=str(tmp_path) + os.sep, fps=24, sampling_rate=None) + saved = _save_output(np.zeros((2, 8, 8, 3), dtype=np.float32), args) + assert [Path(p).suffix for p in saved] == [".png", ".png"] + assert all(Path(p).exists() for p in saved) + + def test_save_output_tensor_shapes(self, tmp_path, monkeypatch): + # `output_type="pt"` outputs are channels-first tensors: (B, C, F, H, W) video saves one + # mp4 per batch item, (B, C, H, W) images save one png per item. + import torch + + exported: list[tuple[int, str]] = [] + monkeypatch.setattr( + "diffusers.commands.run.export_to_video", + lambda frames, path, fps: exported.append((len(frames), path)), + ) + args = Namespace(output=str(tmp_path) + os.sep, fps=24, sampling_rate=None) + saved = _save_output(torch.zeros((2, 3, 4, 8, 8)), args) + assert [Path(p).suffix for p in saved] == [".mp4", ".mp4"] + assert [n for n, _ in exported] == [4, 4] + + saved = _save_output(torch.zeros((2, 3, 8, 8)), args) + assert [Path(p).suffix for p in saved] == [".png", ".png"] + assert all(Path(p).exists() for p in saved) + + def test_save_output_nested_pil_video_batch(self, tmp_path, monkeypatch): + # list[list[PIL]] (video pipelines under explicit output_type="pil") saves one mp4 per + # inner sequence instead of falling through to the JSON dump. + from PIL import Image + + exported: list[str] = [] + monkeypatch.setattr("diffusers.commands.run.export_to_video", lambda frames, path, fps: exported.append(path)) + frames = [Image.new("RGB", (8, 8)) for _ in range(3)] + args = Namespace(output=str(tmp_path) + os.sep, fps=24, sampling_rate=None) + saved = _save_output([frames, frames], args) + assert [Path(p).suffix for p in saved] == [".mp4", ".mp4"] + assert len(exported) == 2 + + def test_save_output_video_export_failure_saves_frames(self, tmp_path, monkeypatch): + # A broken export backend must not discard generated frames: each video's raw frames + # save as a (num_frames, H, W, C) .pt tensor instead. + import numpy as np + import torch + + def broken_export(frames, path, fps): + raise ImportError("no video backend") + + monkeypatch.setattr("diffusers.commands.run.export_to_video", broken_export) + args = Namespace(output=str(tmp_path) + os.sep, fps=24, sampling_rate=None) + saved = _save_output(np.zeros((2, 3, 8, 8, 3), dtype=np.float32), args) + assert [Path(p).suffix for p in saved] == [".pt", ".pt"] + assert torch.load(saved[0]).shape == (3, 8, 8, 3) + class TestSchemaCommand: pretrained_model_name_or_path = "hf-internal-testing/tiny-flux-pipe" @@ -306,6 +379,22 @@ def test_class_discovery(self, tmp_path): with pytest.raises(ValueError, match="Could not parse"): cmd._get_class_names(broken) + def test_packaging_writes_pipeline_index(self, tmp_path, monkeypatch): + # The packaged dir must be loadable by `ModularPipeline.from_pretrained` (what + # `diffusers-cli run` uses), which requires `modular_model_index.json` in addition to + # the block-level `modular_config.json`. + block_py = tmp_path / "block.py" + block_py.write_text( + "from diffusers.modular_pipelines import ModularPipelineBlocks\n" + "\n" + "class MyBlock(ModularPipelineBlocks):\n" + " model_name = 'test'\n" + ) + monkeypatch.chdir(tmp_path) + CustomBlocksCommand(str(block_py), "MyBlock").run() + assert (tmp_path / "modular_config.json").exists() + assert (tmp_path / "modular_model_index.json").exists() + class TestCli: def test_toplevel_help_lists_all_commands(self): From cf4219782295d4ea40fad8a2ff71b2d77e43c205 Mon Sep 17 00:00:00 2001 From: DN6 Date: Tue, 4 Aug 2026 13:57:32 +0530 Subject: [PATCH 02/24] update --- src/diffusers/commands/run.py | 28 ---------------------------- 1 file changed, 28 deletions(-) diff --git a/src/diffusers/commands/run.py b/src/diffusers/commands/run.py index bf06e1a714a2..a0ff6fe13121 100644 --- a/src/diffusers/commands/run.py +++ b/src/diffusers/commands/run.py @@ -757,33 +757,6 @@ def _save_audio_arrays(audios, sampling_rate: int, args: Namespace) -> list[str] return saved -def _warn_missing_video_export_backend() -> None: - """Warn before pipeline load if video output could not be written as mp4. - - Runs pre-flight (like early media resolution) both locally and inside the `--remote` sandbox, so a broken backend - surfaces before minutes of download and inference rather than at save time. A warning, not an error: image/audio - outputs don't need the backend, and `_save_videos` falls back to `.pt` frame dumps. - """ - try: - import imageio # noqa: F401 - import imageio_ffmpeg # noqa: F401 - - return - except ImportError: - pass - try: - # cv2 raises OSError (not ImportError) when its native system libraries are missing. - import cv2 # noqa: F401 - - return - except Exception: - pass - logger.warning( - "No working video export backend found — if this pipeline outputs video, raw frames will " - "be saved as `.pt` tensors instead of mp4. Install one with: pip install imageio imageio-ffmpeg" - ) - - def _save_videos(videos: list[Any], args: Namespace) -> list[str]: """Write each frame sequence to mp4, one file per video. @@ -1234,7 +1207,6 @@ def run(self) -> None: # 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) - _warn_missing_video_export_backend() pipeline = _load_pipeline(self.args) is_modular = isinstance(pipeline, diffusers.ModularPipeline) From 466aee1d7637c8705af76759e8b2cc1e665922f9 Mon Sep 17 00:00:00 2001 From: DN6 Date: Tue, 4 Aug 2026 16:07:44 +0530 Subject: [PATCH 03/24] update --- src/diffusers/commands/run.py | 110 ++++++++++++++++------------------ 1 file changed, 53 insertions(+), 57 deletions(-) diff --git a/src/diffusers/commands/run.py b/src/diffusers/commands/run.py index a0ff6fe13121..c9ab878a5e12 100644 --- a/src/diffusers/commands/run.py +++ b/src/diffusers/commands/run.py @@ -20,7 +20,6 @@ from __future__ import annotations -import inspect import io import json import os @@ -655,15 +654,19 @@ def _get_generator(seed: int | None, device: str): 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 +def _unwrap_pipeline_output(result: Any) -> list[Any]: + """Resolve a pipeline-output object into the media payloads the saver dispatches on. + + An output can carry more than one media field (e.g. LTX2 returns video in `frames` and a waveform in `audio`), so + every known field that is present is saved, not just the first match. Payloads keep their batch dimension — + `_save_output` dispatches on the full batched shape. + """ + payloads = [ + getattr(result, name) + for name in ("images", "frames", "audios", "audio") + if getattr(result, name, None) is not None + ] + return payloads or [result] # --------------------------------------------------------------------------- @@ -721,6 +724,8 @@ def _as_frame_sequence(value: Any): def _as_audio_arrays(value: Any): if isinstance(value, np.ndarray) and value.ndim <= 2: return [value] + if isinstance(value, np.ndarray) and value.ndim == 3: + return list(value) if isinstance(value, (list, tuple)) and value and all(isinstance(v, np.ndarray) for v in value): return list(value) return None @@ -758,39 +763,46 @@ def _save_audio_arrays(audios, sampling_rate: int, args: Namespace) -> list[str] def _save_videos(videos: list[Any], args: Namespace) -> list[str]: - """Write each frame sequence to mp4, one file per video. + """Write each frame sequence to mp4, plus every frame as `-frame-.png` beside it. - The frames took real GPU time to produce, so a missing or broken export backend must never discard them: on export - failure each video's raw frames are saved as a `.pt` tensor instead and a warning tells the user how to export - them. + The stem prefix ties each frame to its video and keeps basenames unique across a batch — required by `--push-to`, + which uploads by basename. Frames are written first and need no video backend, so they double as the safety net: if + `export_to_video` fails (e.g. `imageio` missing), the frames are already on disk and only the mp4 is skipped. """ mp4_paths = _resolve_output_paths(len(videos), args.output, ext="mp4") - try: - for frames, path in zip(videos, mp4_paths): - export_to_video(list(frames), str(path), fps=args.fps) - return [str(p) for p in mp4_paths] - except Exception as e: - pt_paths = _resolve_output_paths(len(videos), args.output, ext="pt") - for frames, path in zip(videos, pt_paths): - torch.save(torch.as_tensor(np.stack([np.asarray(frame) for frame in frames])), path) - logger.warning( - f"Video export failed ({e}); saved the raw frames of {len(videos)} video(s) as " - f"(num_frames, H, W, C) `.pt` tensors under {Path(pt_paths[0]).parent} instead — " - "nothing was discarded. Load with `torch.load` and write with " - "`diffusers.utils.export_to_video` once `imageio` and `imageio-ffmpeg` are installed." - ) - return [str(p) for p in pt_paths] + saved: list[str] = [] + for frames, path in zip(videos, mp4_paths): + frames = list(frames) + for i, frame in enumerate(frames): + if not isinstance(frame, Image.Image): + arr = np.asarray(frame) + if arr.dtype != np.uint8: + arr = (np.clip(arr, 0.0, 1.0) * 255).round().astype(np.uint8) + frame = Image.fromarray(arr) + frame_path = path.with_name(f"{path.stem}-frame-{i:04d}.png") + frame.save(frame_path) + saved.append(str(frame_path)) + try: + export_to_video(frames, str(path), fps=args.fps) + saved.append(str(path)) + except Exception as e: + logger.warning( + f"Video export failed ({e}); the individual frames of {path.stem} are saved next to it as PNGs. " + "Install a video backend with: pip install imageio imageio-ffmpeg" + ) + return saved def _save_output(value: Any, args: Namespace) -> list[str]: """Save `value` by dispatching on its runtime type and, for arrays, its shape.""" - # `run` asks pipelines for `output_type="pt"`; tensor outputs are channels-first - # ((B, C, H, W) images, (B, C, F, H, W) video), while the array branches below expect - # channels-last — convert here so one set of shape branches handles both. + # Tensors arrive only when the user explicitly asked for `output_type="pt"` (or the pipeline + # natively defaults to it, e.g. StableAudio). Postprocessed pt outputs are channels-first per + # frame — (B, C, H, W) images, (B, F, C, H, W) video from `postprocess_video` — while the array + # branches below expect channels-last, so convert here. if isinstance(value, torch.Tensor): arr = value.detach().to(torch.float32).cpu().numpy() if arr.ndim == 5: - arr = arr.transpose(0, 2, 3, 4, 1) + arr = arr.transpose(0, 1, 3, 4, 2) elif arr.ndim == 4: arr = arr.transpose(0, 2, 3, 1) value = arr @@ -828,11 +840,10 @@ def _save_output(value: Any, args: Namespace) -> list[str]: if audios is not None: return _save_audio_arrays(audios, args.sampling_rate or 16000, args) - # Anything that isn't a recognized media shape is saved as a torch tensor: `torch.save` - # handles tensors natively and pickles everything else, so no output is ever dropped. - path = _resolve_output_paths(1, args.output, ext="pt")[0] - torch.save(value, path) - return [str(path)] + raise ValueError( + f"Cannot save pipeline output of type {type(value).__name__!r}: not a recognized image, video, or audio " + "payload. For modular pipelines, pass `--output-key` to select a savable intermediate (e.g. `images`)." + ) # --------------------------------------------------------------------------- @@ -1210,13 +1221,6 @@ def run(self) -> None: pipeline = _load_pipeline(self.args) is_modular = isinstance(pipeline, diffusers.ModularPipeline) - # Ask for tensors instead of PIL: the shape then tells `_save_output` exactly what the - # output is — a flat PIL list can't distinguish one video from a batch of images. An - # explicit user `output_type` always wins. - if not is_modular and "output_type" not in call_kwargs: - if "output_type" in inspect.signature(pipeline.__call__).parameters: - call_kwargs["output_type"] = "pt" - if self.args.output_key is not None: call_kwargs["output"] = self.args.output_key @@ -1232,18 +1236,10 @@ def run(self) -> None: # 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) - try: - saved = _save_output(savable, self.args) - except Exception as e: - # The output took real GPU time to produce — never let a save failure - # discard it. torch.save handles tensors natively and pickles everything - # else (ndarrays, PIL images, lists). - - path = _resolve_output_paths(1, self.args.output, ext="pt")[0] - torch.save(savable, path) - logger.warning(f"Saving the output failed ({e}); saving pipeline output tensors to {path} ") - saved = [str(path)] + savables = [result] if is_modular else _unwrap_pipeline_output(result) + saved = [] + for savable in savables: + saved.extend(_save_output(savable, self.args)) pushed = _push_outputs(self.args, saved) out.result( From b40fc7f12dba5de9a669944179bc1d0654cdc3c6 Mon Sep 17 00:00:00 2001 From: DN6 Date: Tue, 4 Aug 2026 16:53:48 +0530 Subject: [PATCH 04/24] update --- tests/others/test_cli_commands.py | 86 +++++++++++++++++-------------- 1 file changed, 46 insertions(+), 40 deletions(-) diff --git a/tests/others/test_cli_commands.py b/tests/others/test_cli_commands.py index 08bc63b452f6..93da82235bd0 100644 --- a/tests/others/test_cli_commands.py +++ b/tests/others/test_cli_commands.py @@ -32,6 +32,7 @@ _resolve_dtype, _resolve_media_inputs, _save_output, + _unwrap_pipeline_output, _upload_inputs_to_sandbox, ) from diffusers.commands.schema import _parse_docstring_args @@ -250,9 +251,10 @@ def test_attention_backend_arg(self): } assert backends == {AttentionBackendName.FLASH_HUB} - def test_save_output_ndarray_video_batch(self, tmp_path, monkeypatch): - # (B, F, H, W, C) arrays are batched video: one mp4 per batch item. - import numpy as np + def test_save_output_video_saves_mp4_and_frames(self, tmp_path, monkeypatch): + # `output_type="pt"` video is (B, F, C, H, W) from `postprocess_video`: one mp4 per batch + # item, plus every frame as `-frame-.png` beside it. + import torch exported: list[tuple[int, str]] = [] monkeypatch.setattr( @@ -260,41 +262,26 @@ def test_save_output_ndarray_video_batch(self, tmp_path, monkeypatch): lambda frames, path, fps: exported.append((len(frames), path)), ) args = Namespace(output=str(tmp_path) + os.sep, fps=24, sampling_rate=None) - saved = _save_output(np.zeros((2, 3, 8, 8, 3), dtype=np.float32), args) - assert [Path(p).suffix for p in saved] == [".mp4", ".mp4"] - assert [n for n, _ in exported] == [3, 3] - - def test_save_output_ndarray_image_batch(self, tmp_path): - # (B, H, W, C) arrays are batched images: one png per item. - import numpy as np - - args = Namespace(output=str(tmp_path) + os.sep, fps=24, sampling_rate=None) - saved = _save_output(np.zeros((2, 8, 8, 3), dtype=np.float32), args) - assert [Path(p).suffix for p in saved] == [".png", ".png"] - assert all(Path(p).exists() for p in saved) + saved = _save_output(torch.zeros((2, 4, 3, 8, 8)), args) + names = sorted(Path(p).name for p in saved) + assert [n for n, _ in exported] == [4, 4] + assert [n for n in names if n.endswith(".mp4")] == ["0000.mp4", "0001.mp4"] + frame_names = [n for n in names if n.endswith(".png")] + assert frame_names == sorted(f"{v:04d}-frame-{i:04d}.png" for v in range(2) for i in range(4)) + assert all((tmp_path / n).exists() for n in frame_names) - def test_save_output_tensor_shapes(self, tmp_path, monkeypatch): - # `output_type="pt"` outputs are channels-first tensors: (B, C, F, H, W) video saves one - # mp4 per batch item, (B, C, H, W) images save one png per item. + def test_save_output_tensor_image_batch(self, tmp_path): + # `output_type="pt"` images are channels-first (B, C, H, W): one png per batch item. import torch - exported: list[tuple[int, str]] = [] - monkeypatch.setattr( - "diffusers.commands.run.export_to_video", - lambda frames, path, fps: exported.append((len(frames), path)), - ) args = Namespace(output=str(tmp_path) + os.sep, fps=24, sampling_rate=None) - saved = _save_output(torch.zeros((2, 3, 4, 8, 8)), args) - assert [Path(p).suffix for p in saved] == [".mp4", ".mp4"] - assert [n for n, _ in exported] == [4, 4] - saved = _save_output(torch.zeros((2, 3, 8, 8)), args) assert [Path(p).suffix for p in saved] == [".png", ".png"] assert all(Path(p).exists() for p in saved) def test_save_output_nested_pil_video_batch(self, tmp_path, monkeypatch): - # list[list[PIL]] (video pipelines under explicit output_type="pil") saves one mp4 per - # inner sequence instead of falling through to the JSON dump. + # list[list[PIL]] (video pipelines under their default output_type="pil") saves one mp4 + # per inner sequence, plus the per-frame pngs. from PIL import Image exported: list[str] = [] @@ -302,23 +289,42 @@ def test_save_output_nested_pil_video_batch(self, tmp_path, monkeypatch): frames = [Image.new("RGB", (8, 8)) for _ in range(3)] args = Namespace(output=str(tmp_path) + os.sep, fps=24, sampling_rate=None) saved = _save_output([frames, frames], args) - assert [Path(p).suffix for p in saved] == [".mp4", ".mp4"] assert len(exported) == 2 + assert sorted(Path(p).suffix for p in saved) == [".mp4"] * 2 + [".png"] * 6 + + def test_save_output_stereo_audio(self, tmp_path): + # (B, C, samples) waveforms (e.g. StableAudio's native output_type="pt") save as + # multi-channel wavs instead of falling through as unrecognized. + import wave - def test_save_output_video_export_failure_saves_frames(self, tmp_path, monkeypatch): - # A broken export backend must not discard generated frames: each video's raw frames - # save as a (num_frames, H, W, C) .pt tensor instead. - import numpy as np import torch - def broken_export(frames, path, fps): - raise ImportError("no video backend") + args = Namespace(output=str(tmp_path) + os.sep, fps=24, sampling_rate=44100) + saved = _save_output(torch.zeros((1, 2, 1000)), args) + assert [Path(p).suffix for p in saved] == [".wav"] + with wave.open(saved[0]) as w: + assert w.getnchannels() == 2 + assert w.getnframes() == 1000 + + def test_unwrap_pipeline_output_multi_media(self): + # Every media field present on an output is saved, not just the first match (LTX2 returns + # both `frames` and `audio`), and payloads keep their batch dimension. + import torch + + class Output: + frames = torch.zeros((1, 4, 3, 8, 8)) + audio = torch.zeros((1, 2, 1000)) + + payloads = _unwrap_pipeline_output(Output()) + assert len(payloads) == 2 + assert payloads[0].shape == (1, 4, 3, 8, 8) + assert payloads[1].shape == (1, 2, 1000) - monkeypatch.setattr("diffusers.commands.run.export_to_video", broken_export) + def test_save_output_unrecognized_raises(self, tmp_path): + # Unrecognized payloads (e.g. a modular PipelineState) raise instead of being pickled. args = Namespace(output=str(tmp_path) + os.sep, fps=24, sampling_rate=None) - saved = _save_output(np.zeros((2, 3, 8, 8, 3), dtype=np.float32), args) - assert [Path(p).suffix for p in saved] == [".pt", ".pt"] - assert torch.load(saved[0]).shape == (3, 8, 8, 3) + with pytest.raises(ValueError, match="--output-key"): + _save_output({"not": "media"}, args) class TestSchemaCommand: From 9bf9381f51d9a4d484596358c4f5c90bb309d86f Mon Sep 17 00:00:00 2001 From: DN6 Date: Tue, 4 Aug 2026 18:36:58 +0530 Subject: [PATCH 05/24] add peft to default remote deps Co-Authored-By: Claude Fable 5 --- src/diffusers/commands/run.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/diffusers/commands/run.py b/src/diffusers/commands/run.py index c9ab878a5e12..c545c36c42e5 100644 --- a/src/diffusers/commands/run.py +++ b/src/diffusers/commands/run.py @@ -96,6 +96,7 @@ "safetensors", "sentencepiece", # required by several text-encoder tokenizers (T5, LLaMA, …) "ftfy", # required by older CLIP text-encoder paths + "peft", # required by `load_lora_weights` when `--lora` is passed "imageio", # preferred `export_to_video` backend "imageio-ffmpeg", # bundles a static ffmpeg; the cv2 fallback needs system libs the slim image lacks ) From a77c04427fe405e47e90786b03fb5328150c4b26 Mon Sep 17 00:00:00 2001 From: Akshan Krithick <97239696+akshan-main@users.noreply.github.com> Date: Mon, 3 Aug 2026 01:32:01 -0700 Subject: [PATCH 06/24] refactor flux2 klein inpaint pipeline tests to the new mixin structure (#14337) Co-authored-by: Sayak Paul --- .../test_pipeline_flux2_klein_inpaint.py | 90 ++++++++----------- 1 file changed, 38 insertions(+), 52 deletions(-) diff --git a/tests/pipelines/flux2/test_pipeline_flux2_klein_inpaint.py b/tests/pipelines/flux2/test_pipeline_flux2_klein_inpaint.py index 665abf7ff93e..a8385f66db21 100644 --- a/tests/pipelines/flux2/test_pipeline_flux2_klein_inpaint.py +++ b/tests/pipelines/flux2/test_pipeline_flux2_klein_inpaint.py @@ -1,7 +1,6 @@ import random -import unittest -import numpy as np +import pytest import torch from transformers import Qwen2TokenizerFast, Qwen3Config, Qwen3ForCausalLM @@ -12,27 +11,16 @@ Flux2Transformer2DModel, ) -from ...testing_utils import ( - enable_full_determinism, - floats_tensor, - torch_device, -) -from ..test_pipelines_common import PipelineTesterMixin - - -enable_full_determinism() +from ...testing_utils import floats_tensor, torch_device +from ..testing_utils import BasePipelineTesterConfig, MemoryTesterMixin, PipelineTesterMixin -class Flux2KleinInpaintPipelineFastTests(PipelineTesterMixin, unittest.TestCase): +class Flux2KleinInpaintPipelineTesterConfig(BasePipelineTesterConfig): pipeline_class = Flux2KleinInpaintPipeline - params = frozenset( + required_input_params_in_call_signature = frozenset( ["prompt", "image", "image_reference", "mask_image", "height", "width", "guidance_scale", "prompt_embeds"] ) - batch_params = frozenset(["prompt", "image", "image_reference", "mask_image"]) - - test_xformers_attention = False - test_layerwise_casting = True - test_group_offloading = True + batch_input_params = frozenset(["prompt", "image", "image_reference", "mask_image"]) def get_dummy_components(self, num_layers: int = 1, num_single_layers: int = 1): torch.manual_seed(0) @@ -92,49 +80,47 @@ def get_dummy_components(self, num_layers: int = 1, num_single_layers: int = 1): "vae": vae, } - def get_dummy_inputs(self, device, seed=0): - image = floats_tensor((1, 3, 32, 32), rng=random.Random(seed)).to(device) - mask_image = torch.ones((1, 1, 32, 32)).to(device) - - if str(device).startswith("mps"): - generator = torch.manual_seed(seed) - else: - generator = torch.Generator(device="cpu").manual_seed(seed) + def get_dummy_inputs(self): + image = floats_tensor((1, 3, 32, 32), rng=random.Random(0)).to(torch_device) + mask_image = torch.ones((1, 1, 32, 32)).to(torch_device) inputs = { "prompt": "A painting of a squirrel eating a burger", "image": image, "mask_image": mask_image, - "generator": generator, + "generator": self.get_generator(0), "num_inference_steps": 2, "guidance_scale": 8.0, "height": 32, "width": 32, "max_sequence_length": 64, "strength": 0.8, - "output_type": "np", "text_encoder_out_layers": (1,), + # Request torch outputs so tests compare torch tensors directly (see `BasePipelineTesterConfig`). + "output_type": "pt", } return inputs + +class TestFlux2KleinInpaintPipeline(Flux2KleinInpaintPipelineTesterConfig, PipelineTesterMixin): def test_flux2_klein_inpaint_different_prompts(self): - pipe = self.pipeline_class(**self.get_dummy_components()).to(torch_device) + pipe = self.get_pipeline().to(torch_device) - inputs = self.get_dummy_inputs(torch_device) + inputs = self.get_dummy_inputs() output_same_prompt = pipe(**inputs).images[0] - inputs = self.get_dummy_inputs(torch_device) + inputs = self.get_dummy_inputs() inputs["prompt"] = "a different prompt" output_different_prompts = pipe(**inputs).images[0] - max_diff = np.abs(output_same_prompt - output_different_prompts).max() + max_diff = (output_same_prompt - output_different_prompts).abs().max() # Outputs should be different here - assert max_diff > 1e-6 + assert max_diff > 1e-6, "Outputs should be different for different prompts." def test_flux2_klein_inpaint_image_output_shape(self): - pipe = self.pipeline_class(**self.get_dummy_components()).to(torch_device) - inputs = self.get_dummy_inputs(torch_device) + pipe = self.get_pipeline().to(torch_device) + inputs = self.get_dummy_inputs() height_width_pairs = [(32, 32), (72, 56)] for height, width in height_width_pairs: @@ -147,34 +133,32 @@ def test_flux2_klein_inpaint_image_output_shape(self): inputs.update({"height": height, "width": width, "image": image, "mask_image": mask_image}) image = pipe(**inputs).images[0] - output_height, output_width, _ = image.shape - self.assertEqual( - (output_height, output_width), - (expected_height, expected_width), - f"Output shape {image.shape} does not match expected shape {(expected_height, expected_width)}", + _, output_height, output_width = image.shape + assert (output_height, output_width) == (expected_height, expected_width), ( + f"Output shape {image.shape} does not match expected shape {(expected_height, expected_width)}" ) def test_flux2_klein_inpaint_strength(self): - pipe = self.pipeline_class(**self.get_dummy_components()).to(torch_device) + pipe = self.get_pipeline().to(torch_device) # Test with strength=1.0 (full denoising) - inputs = self.get_dummy_inputs(torch_device) + inputs = self.get_dummy_inputs() inputs["strength"] = 1.0 output_full_strength = pipe(**inputs).images[0] # Test with strength=0.5 (partial denoising) - inputs = self.get_dummy_inputs(torch_device) + inputs = self.get_dummy_inputs() inputs["strength"] = 0.5 output_half_strength = pipe(**inputs).images[0] - max_diff = np.abs(output_full_strength - output_half_strength).max() + max_diff = (output_full_strength - output_half_strength).abs().max() # Outputs should be different with different strength values assert max_diff > 1e-6 def test_flux2_klein_inpaint_image_reference(self): - pipe = self.pipeline_class(**self.get_dummy_components()).to(torch_device) - inputs = self.get_dummy_inputs(torch_device) + pipe = self.get_pipeline().to(torch_device) + inputs = self.get_dummy_inputs() # Add a reference image to the inputs ref_image = floats_tensor((1, 3, 32, 32), rng=random.Random(1)).to(torch_device) @@ -185,13 +169,15 @@ def test_flux2_klein_inpaint_image_reference(self): expected_height = inputs["height"] - inputs["height"] % (pipe.vae_scale_factor * 2) expected_width = inputs["width"] - inputs["width"] % (pipe.vae_scale_factor * 2) - output_height, output_width, _ = image.shape - self.assertEqual( - (output_height, output_width), - (expected_height, expected_width), - f"Output shape {image.shape} does not match expected shape {(expected_height, expected_width)} when conditioned on a reference image.", + _, output_height, output_width = image.shape + assert (output_height, output_width) == (expected_height, expected_width), ( + f"Output shape {image.shape} does not match expected shape {(expected_height, expected_width)} when conditioned on a reference image." ) - @unittest.skip("Needs to be revisited") + @pytest.mark.skip("Needs to be revisited") def test_encode_prompt_works_in_isolation(self): pass + + +class TestFlux2KleinInpaintPipelineMemory(Flux2KleinInpaintPipelineTesterConfig, MemoryTesterMixin): + """Memory optimization tests (CPU offload, group offload, layerwise casting) for the Flux2 Klein inpaint pipeline.""" From 53116531d8365893d808cfc714bcd0486f50d8f0 Mon Sep 17 00:00:00 2001 From: Sayak Paul Date: Mon, 3 Aug 2026 22:28:24 +0700 Subject: [PATCH 07/24] [kernels] download kernels when users request for it. (#14298) * download kernels when users request for it. * address review feedback --- docs/source/en/optimization/attention_backends.md | 14 ++++++++++++++ docs/source/en/quantization/gguf.md | 2 ++ docs/source/en/quantization/nunchaku.md | 2 ++ src/diffusers/quantizers/gguf/utils.py | 13 +++++++++++-- src/diffusers/quantizers/nunchaku/utils.py | 12 ++++++++++-- src/diffusers/utils/constants.py | 5 +++++ 6 files changed, 44 insertions(+), 4 deletions(-) diff --git a/docs/source/en/optimization/attention_backends.md b/docs/source/en/optimization/attention_backends.md index 022eb0a8830a..79af0bb00685 100644 --- a/docs/source/en/optimization/attention_backends.md +++ b/docs/source/en/optimization/attention_backends.md @@ -82,6 +82,20 @@ with attention_backend("_flash_3_hub"): > [!TIP] > Most attention backends support `torch.compile` without graph breaks and can be used to further speed up inference. +## Trusting remote kernels + +Hub backends and other kernel-backed features (such as [GGUF](../quantization/gguf) and [Nunchaku Lite](../quantization/nunchaku)) download compute kernels from the Hub with [`kernels`](https://github.com/huggingface/kernels) and execute their code locally. + +By default, `kernels` only loads a kernel when its publisher is a trusted kernel publisher on the Hub. Kernels published under the [`kernels-community`](https://huggingface.co/kernels-community) organization are trusted, so Diffusers loads them without any additional configuration. The `_flash_3_hub`, `flash_hub`, `sage_hub`, and the other Hub attention backends all resolve to `kernels-community` repositories. + +Kernels from any other publisher are not vetted. Loading one downloads and runs code that Diffusers cannot vouch for, so Diffusers keeps it disabled unless you explicitly opt in with the `DIFFUSERS_TRUST_REMOTE_KERNELS` environment variable. When set, Diffusers forwards `trust_remote_code=True` to `kernels` so it loads kernels from untrusted publishers too. + +```bash +export DIFFUSERS_TRUST_REMOTE_KERNELS=true +``` + +Only enable this after inspecting the kernel repository, since it grants the downloaded code the ability to run on your machine. Without it, loading a kernel from an untrusted publisher raises an error. Diffusers performs this check itself, so it also applies to `kernels<0.14.0`, which predates the `trust_remote_code` argument. Setting `DIFFUSERS_DISABLE_REMOTE_CODE=true` disables remote code globally and takes precedence over `DIFFUSERS_TRUST_REMOTE_KERNELS`. + ## Checks The attention dispatcher includes debugging checks that catch common errors before they cause problems. diff --git a/docs/source/en/quantization/gguf.md b/docs/source/en/quantization/gguf.md index 6ee91e2b272f..94615867c6e8 100644 --- a/docs/source/en/quantization/gguf.md +++ b/docs/source/en/quantization/gguf.md @@ -63,6 +63,8 @@ pip install -U kernels Once installed, set `DIFFUSERS_GGUF_CUDA_KERNELS=true` to use optimized kernels when available. Note that CUDA kernels may introduce minor numerical differences compared to the original GGUF implementation, potentially causing subtle visual variations in generated images. To disable CUDA kernel usage, set the environment variable `DIFFUSERS_GGUF_CUDA_KERNELS=false`. +The GGUF kernels are downloaded from the [`Isotr0py/ggml`](https://huggingface.co/Isotr0py/ggml) repository, whose publisher is not a trusted kernel publisher on the Hub. Loading it downloads and executes code from the Hub, so Diffusers requires you to explicitly opt in by setting `DIFFUSERS_TRUST_REMOTE_KERNELS=true`. See [Trusting remote kernels](../optimization/attention_backends#trusting-remote-kernels) for details. + ## Supported Quantization Types - BF16 diff --git a/docs/source/en/quantization/nunchaku.md b/docs/source/en/quantization/nunchaku.md index 1cdad66917b5..ceb663336ef6 100644 --- a/docs/source/en/quantization/nunchaku.md +++ b/docs/source/en/quantization/nunchaku.md @@ -27,6 +27,8 @@ The kernels package supplies the optimized CUDA kernels, which load automaticall pip install -U kernels ``` +Nunchaku Lite loads its kernels from the [`rootonchair/nunchaku-lite-kernels`](https://huggingface.co/rootonchair/nunchaku-lite-kernels) repository, whose publisher is not a trusted kernel publisher on the Hub. Loading it downloads and executes code from the Hub, so Diffusers requires you to explicitly opt in by setting `DIFFUSERS_TRUST_REMOTE_KERNELS=true`. See [Trusting remote kernels](../optimization/attention_backends#trusting-remote-kernels) for details. + ## Load a quantized pipeline Load the prequantized pipeline with [`~DiffusionPipeline.from_pretrained`], which reads the quantization diff --git a/src/diffusers/quantizers/gguf/utils.py b/src/diffusers/quantizers/gguf/utils.py index 409c2a3e3987..2aa682ab002e 100644 --- a/src/diffusers/quantizers/gguf/utils.py +++ b/src/diffusers/quantizers/gguf/utils.py @@ -20,7 +20,8 @@ import torch import torch.nn as nn -from ...utils import is_accelerate_available, is_kernels_available +from ...utils import is_accelerate_available, is_kernels_available, is_kernels_version +from ...utils.constants import DIFFUSERS_TRUST_REMOTE_KERNELS if is_accelerate_available(): @@ -37,7 +38,15 @@ if can_use_cuda_kernels and is_kernels_available(): from kernels import get_kernel - ops = get_kernel("Isotr0py/ggml") + if not DIFFUSERS_TRUST_REMOTE_KERNELS: + raise ValueError( + "`Isotr0py/ggml` is not published by a trusted kernel publisher on the Hub, so loading it downloads " + "and executes remote code. Set `DIFFUSERS_TRUST_REMOTE_KERNELS=true` to allow it, or set " + "`DIFFUSERS_GGUF_CUDA_KERNELS=false` to run without the CUDA kernels." + ) + # `kernels<0.14.0` has no `trust_remote_code` argument and executes the downloaded code unconditionally. + trust_kwargs = {"trust_remote_code": True} if is_kernels_version(">=", "0.14.0") else {} + ops = get_kernel("Isotr0py/ggml", **trust_kwargs) else: ops = None diff --git a/src/diffusers/quantizers/nunchaku/utils.py b/src/diffusers/quantizers/nunchaku/utils.py index eda802437765..cca40e2799eb 100644 --- a/src/diffusers/quantizers/nunchaku/utils.py +++ b/src/diffusers/quantizers/nunchaku/utils.py @@ -8,7 +8,8 @@ import torch import torch.nn as nn -from ...utils import is_accelerate_available, is_kernels_available +from ...utils import is_accelerate_available, is_kernels_available, is_kernels_version +from ...utils.constants import DIFFUSERS_TRUST_REMOTE_KERNELS if is_accelerate_available(): @@ -22,7 +23,14 @@ if is_kernels_available(): from kernels import get_kernel - ops = get_kernel(_HF_KERNEL_REPO, version=_HF_KERNEL_VERSION, trust_remote_code=True).ops + if not DIFFUSERS_TRUST_REMOTE_KERNELS: + raise ValueError( + f"`{_HF_KERNEL_REPO}` is not published by a trusted kernel publisher on the Hub, so loading it " + "downloads and executes remote code. Set `DIFFUSERS_TRUST_REMOTE_KERNELS=true` to allow it." + ) + # `kernels<0.14.0` has no `trust_remote_code` argument and executes the downloaded code unconditionally. + trust_kwargs = {"trust_remote_code": True} if is_kernels_version(">=", "0.14.0") else {} + ops = get_kernel(_HF_KERNEL_REPO, version=_HF_KERNEL_VERSION, **trust_kwargs).ops else: raise ImportError( "Loading Nunchaku checkpoints requires the Hugging Face `kernels` package. " diff --git a/src/diffusers/utils/constants.py b/src/diffusers/utils/constants.py index 6deb523061d3..429a1ea9566c 100644 --- a/src/diffusers/utils/constants.py +++ b/src/diffusers/utils/constants.py @@ -48,6 +48,11 @@ HF_ENABLE_PARALLEL_LOADING = os.environ.get("HF_ENABLE_PARALLEL_LOADING", "").upper() in ENV_VARS_TRUE_VALUES DIFFUSERS_DISABLE_REMOTE_CODE = os.getenv("DIFFUSERS_DISABLE_REMOTE_CODE", "false").upper() in ENV_VARS_TRUE_VALUES DIFFUSERS_SDNQ_TRANSFORMERS = os.getenv("DIFFUSERS_SDNQ_TRANSFORMERS", "false").upper() in ENV_VARS_TRUE_VALUES +# Kernels published by untrusted publishers execute remote code, so a globally disabled remote code wins over the opt-in. +DIFFUSERS_TRUST_REMOTE_KERNELS = ( + os.getenv("DIFFUSERS_TRUST_REMOTE_KERNELS", "false").upper() in ENV_VARS_TRUE_VALUES + and not DIFFUSERS_DISABLE_REMOTE_CODE +) # Below should be `True` if the current version of `peft` and `transformers` are compatible with # PEFT backend. Will automatically fall back to PEFT backend if the correct versions of the libraries are From 8a8d481a319c165b40785ecbe76992162c738778 Mon Sep 17 00:00:00 2001 From: Akshan Krithick <97239696+akshan-main@users.noreply.github.com> Date: Mon, 3 Aug 2026 08:28:37 -0700 Subject: [PATCH 08/24] refactor flux2 klein pipeline tests to the new mixin structure (#14336) * refactor flux2 klein pipeline tests to the new mixin structure * use assert_tensors_close instead of torch.allclose --------- Co-authored-by: Sayak Paul --- .../flux2/test_pipeline_flux2_klein.py | 153 +++++++++--------- 1 file changed, 78 insertions(+), 75 deletions(-) diff --git a/tests/pipelines/flux2/test_pipeline_flux2_klein.py b/tests/pipelines/flux2/test_pipeline_flux2_klein.py index 6db70c6367ab..a38eb4de2a90 100644 --- a/tests/pipelines/flux2/test_pipeline_flux2_klein.py +++ b/tests/pipelines/flux2/test_pipeline_flux2_klein.py @@ -1,8 +1,8 @@ import gc import os -import unittest import numpy as np +import pytest import torch from PIL import Image from transformers import Qwen2TokenizerFast, Qwen3Config, Qwen3ForCausalLM @@ -15,22 +15,26 @@ ) from ...testing_utils import ( + assert_tensors_close, backend_empty_cache, backend_synchronize, require_torch_neuron, torch_device, ) -from ..test_pipelines_common import PipelineTesterMixin, check_qkv_fused_layers_exist +from ..testing_utils import ( + BasePipelineTesterConfig, + MemoryTesterMixin, + PipelineTesterMixin, + check_qkv_fused_layers_exist, +) -class Flux2KleinPipelineFastTests(PipelineTesterMixin, unittest.TestCase): +class Flux2KleinPipelineTesterConfig(BasePipelineTesterConfig): pipeline_class = Flux2KleinPipeline - params = frozenset(["prompt", "height", "width", "guidance_scale", "prompt_embeds"]) - batch_params = frozenset(["prompt"]) - - test_xformers_attention = False - test_layerwise_casting = True - test_group_offloading = True + required_input_params_in_call_signature = frozenset( + ["prompt", "height", "width", "guidance_scale", "prompt_embeds"] + ) + batch_input_params = frozenset(["prompt"]) def get_dummy_components(self, num_layers: int = 1, num_single_layers: int = 1): torch.manual_seed(0) @@ -90,67 +94,70 @@ def get_dummy_components(self, num_layers: int = 1, num_single_layers: int = 1): "vae": vae, } - def get_dummy_inputs(self, device, seed=0): - if str(device).startswith("mps"): - generator = torch.manual_seed(seed) - else: - generator = torch.Generator(device="cpu").manual_seed(seed) - + def get_dummy_inputs(self): inputs = { "prompt": "a dog is dancing", - "generator": generator, + "generator": self.get_generator(0), "num_inference_steps": 2, "guidance_scale": 4.0, "height": 8, "width": 8, "max_sequence_length": 64, - "output_type": "np", + # Request torch outputs so tests compare torch tensors directly (see `BasePipelineTesterConfig`). + # Note `"pt"` images are `(batch, channels, height, width)`, unlike `"np"` (`(batch, h, w, c)`). + "output_type": "pt", "text_encoder_out_layers": (1,), } return inputs + +class TestFlux2KleinPipeline(Flux2KleinPipelineTesterConfig, PipelineTesterMixin): def test_fused_qkv_projections(self): - device = "cpu" # ensure determinism for the device-dependent torch.Generator - components = self.get_dummy_components() - pipe = self.pipeline_class(**components) - pipe = pipe.to(device) - pipe.set_progress_bar_config(disable=None) + pipe = self.get_pipeline() - inputs = self.get_dummy_inputs(device) + inputs = self.get_dummy_inputs() image = pipe(**inputs).images - original_image_slice = image[0, -3:, -3:, -1] + original_image_slice = image[0, -1, -3:, -3:] pipe.transformer.fuse_qkv_projections() - self.assertTrue( - check_qkv_fused_layers_exist(pipe.transformer, ["to_qkv"]), - ("Something wrong with the fused attention layers. Expected all the attention projections to be fused."), + assert check_qkv_fused_layers_exist(pipe.transformer, ["to_qkv"]), ( + "Something wrong with the fused attention layers. Expected all the attention projections to be fused." ) - inputs = self.get_dummy_inputs(device) + inputs = self.get_dummy_inputs() image = pipe(**inputs).images - image_slice_fused = image[0, -3:, -3:, -1] + image_slice_fused = image[0, -1, -3:, -3:] pipe.transformer.unfuse_qkv_projections() - inputs = self.get_dummy_inputs(device) + inputs = self.get_dummy_inputs() image = pipe(**inputs).images - image_slice_disabled = image[0, -3:, -3:, -1] - - self.assertTrue( - np.allclose(original_image_slice, image_slice_fused, atol=1e-3, rtol=1e-3), - ("Fusion of QKV projections shouldn't affect the outputs."), + image_slice_disabled = image[0, -1, -3:, -3:] + + assert_tensors_close( + original_image_slice, + image_slice_fused, + atol=1e-3, + rtol=1e-3, + msg="Fusion of QKV projections shouldn't affect the outputs.", ) - self.assertTrue( - np.allclose(image_slice_fused, image_slice_disabled, atol=1e-3, rtol=1e-3), - ("Outputs, with QKV projection fusion enabled, shouldn't change when fused QKV projections are disabled."), + assert_tensors_close( + image_slice_fused, + image_slice_disabled, + atol=1e-3, + rtol=1e-3, + msg="Outputs, with QKV projection fusion enabled, shouldn't change when fused QKV projections are disabled.", ) - self.assertTrue( - np.allclose(original_image_slice, image_slice_disabled, atol=1e-2, rtol=1e-2), - ("Original outputs should match when fused QKV projections are disabled."), + assert_tensors_close( + original_image_slice, + image_slice_disabled, + atol=1e-2, + rtol=1e-2, + msg="Original outputs should match when fused QKV projections are disabled.", ) def test_image_output_shape(self): - pipe = self.pipeline_class(**self.get_dummy_components()).to(torch_device) - inputs = self.get_dummy_inputs(torch_device) + pipe = self.get_pipeline().to(torch_device) + inputs = self.get_dummy_inputs() height_width_pairs = [(32, 32), (72, 57)] for height, width in height_width_pairs: @@ -159,55 +166,55 @@ def test_image_output_shape(self): inputs.update({"height": height, "width": width}) image = pipe(**inputs).images[0] - output_height, output_width, _ = image.shape - self.assertEqual( - (output_height, output_width), - (expected_height, expected_width), - f"Output shape {image.shape} does not match expected shape {(expected_height, expected_width)}", + _, output_height, output_width = image.shape + assert (output_height, output_width) == (expected_height, expected_width), ( + f"Output shape {image.shape} does not match expected shape {(expected_height, expected_width)}" ) def test_image_input(self): - device = "cpu" - pipe = self.pipeline_class(**self.get_dummy_components()).to(device) - inputs = self.get_dummy_inputs(device) + pipe = self.get_pipeline() + inputs = self.get_dummy_inputs() inputs["image"] = Image.new("RGB", (64, 64)) - image = pipe(**inputs).images.flatten() - generated_slice = np.concatenate([image[:8], image[-8:]]) + # Permute the `"pt"` output to the `"np"` layout before flattening so the slice matches the recorded values. + image = pipe(**inputs).images.permute(0, 2, 3, 1).flatten() + generated_slice = torch.cat([image[:8], image[-8:]]) # fmt: off - expected_slice = np.array( + expected_slice = torch.tensor( [ 0.8255048 , 0.66054785, 0.6643694 , 0.67462724, 0.5494932 , 0.3480271 , 0.52535003, 0.44510138, 0.23549396, 0.21372932, 0.21166152, 0.63198495, 0.49942136, 0.39147034, 0.49156153, 0.3713916 ] ) # fmt: on - assert np.allclose(expected_slice, generated_slice, atol=1e-4, rtol=1e-4) + assert_tensors_close(generated_slice, expected_slice, atol=1e-4, rtol=1e-4) - @unittest.skip("Needs to be revisited") + @pytest.mark.skip("Needs to be revisited") def test_encode_prompt_works_in_isolation(self): pass +class TestFlux2KleinPipelineMemory(Flux2KleinPipelineTesterConfig, MemoryTesterMixin): + """Memory optimization tests (CPU offload, group offload, layerwise casting) for the Flux2 Klein pipeline.""" + + @require_torch_neuron -class Flux2KleinPipelineIntegrationTests(unittest.TestCase): +class TestFlux2KleinPipelineIntegration: ckpt_id = "black-forest-labs/FLUX.2-klein-4B" prompt = "A small cactus with a happy face in the Sahara desert." - def setUp(self): - super().setUp() - self._saved_env = {} + @pytest.fixture(autouse=True) + def neuron_env(self): + saved_env = {} neff_cache_dir = "/tmp/neff_cache" os.makedirs(neff_cache_dir, exist_ok=True) for key in ("TORCH_NEURONX_NEFF_CACHE_DIR", "TORCH_NEURONX_ENABLE_NKI_SDPA"): - self._saved_env[key] = os.environ.get(key) + saved_env[key] = os.environ.get(key) os.environ["TORCH_NEURONX_NEFF_CACHE_DIR"] = neff_cache_dir os.environ.setdefault("TORCH_NEURONX_ENABLE_NKI_SDPA", "0") gc.collect() backend_empty_cache(torch_device) - - def tearDown(self): - super().tearDown() - for key, original in self._saved_env.items(): + yield + for key, original in saved_env.items(): if original is None: os.environ.pop(key, None) else: @@ -234,12 +241,11 @@ def test_flux2_klein_inference_512(self): ).images image_slice = image[0, -3:, -3:, -1] - self.assertEqual(image.shape, (1, 512, 512, 3)) - self.assertTrue(np.all((image >= 0.0) & (image <= 1.0)), "Pixel values must be in [0, 1]") + assert image.shape == (1, 512, 512, 3) + assert np.all((image >= 0.0) & (image <= 1.0)), "Pixel values must be in [0, 1]" expected_slice = np.array([0.3652, 0.3574, 0.3633, 0.4102, 0.4062, 0.4043, 0.4453, 0.4355, 0.4570]) - self.assertLess(np.abs(image_slice.flatten() - expected_slice).max(), 5e-2) + assert np.abs(image_slice.flatten() - expected_slice).max() < 5e-2 - @require_torch_neuron def test_flux2_klein_neuron_compile_128(self): from torch_neuronx.neuron_dynamo_backend import set_model_name @@ -273,9 +279,6 @@ def test_flux2_klein_neuron_compile_128(self): output_type="np", ).images - self.assertEqual(image.shape, (1, 128, 128, 3)) - self.assertFalse(np.isnan(image).any(), "Output contains NaN values") - self.assertTrue( - (image >= 0.0).all() and (image <= 1.0).all(), - "Output pixel values outside [0, 1]", - ) + assert image.shape == (1, 128, 128, 3) + assert not np.isnan(image).any(), "Output contains NaN values" + assert (image >= 0.0).all() and (image <= 1.0).all(), "Output pixel values outside [0, 1]" From e0acd8da95f6524a22fe9be09943e8ebb3d47a90 Mon Sep 17 00:00:00 2001 From: Akshan Krithick <97239696+akshan-main@users.noreply.github.com> Date: Mon, 3 Aug 2026 08:29:06 -0700 Subject: [PATCH 09/24] refactor flux2 klein kv pipeline tests to the new mixin structure (#14344) Co-authored-by: Sayak Paul --- .../flux2/test_pipeline_flux2_klein_kv.py | 115 +++++++++--------- 1 file changed, 59 insertions(+), 56 deletions(-) diff --git a/tests/pipelines/flux2/test_pipeline_flux2_klein_kv.py b/tests/pipelines/flux2/test_pipeline_flux2_klein_kv.py index 4f77579af6d6..141814b92b54 100644 --- a/tests/pipelines/flux2/test_pipeline_flux2_klein_kv.py +++ b/tests/pipelines/flux2/test_pipeline_flux2_klein_kv.py @@ -1,6 +1,4 @@ -import unittest - -import numpy as np +import pytest import torch from PIL import Image from transformers import Qwen2TokenizerFast, Qwen3Config, Qwen3ForCausalLM @@ -12,18 +10,19 @@ Flux2Transformer2DModel, ) -from ...testing_utils import torch_device -from ..test_pipelines_common import PipelineTesterMixin, check_qkv_fused_layers_exist +from ...testing_utils import assert_tensors_close, torch_device +from ..testing_utils import ( + BasePipelineTesterConfig, + MemoryTesterMixin, + PipelineTesterMixin, + check_qkv_fused_layers_exist, +) -class Flux2KleinKVPipelineFastTests(PipelineTesterMixin, unittest.TestCase): +class Flux2KleinKVPipelineTesterConfig(BasePipelineTesterConfig): pipeline_class = Flux2KleinKVPipeline - params = frozenset(["prompt", "height", "width", "prompt_embeds", "image"]) - batch_params = frozenset(["prompt"]) - - test_xformers_attention = False - test_layerwise_casting = True - test_group_offloading = True + required_input_params_in_call_signature = frozenset(["prompt", "height", "width", "prompt_embeds", "image"]) + batch_input_params = frozenset(["prompt"]) def get_dummy_components(self, num_layers: int = 1, num_single_layers: int = 1): torch.manual_seed(0) @@ -83,67 +82,70 @@ def get_dummy_components(self, num_layers: int = 1, num_single_layers: int = 1): "vae": vae, } - def get_dummy_inputs(self, device, seed=0): - if str(device).startswith("mps"): - generator = torch.manual_seed(seed) - else: - generator = torch.Generator(device="cpu").manual_seed(seed) - + def get_dummy_inputs(self): inputs = { "prompt": "a dog is dancing", "image": Image.new("RGB", (64, 64)), - "generator": generator, + "generator": self.get_generator(0), "num_inference_steps": 2, "height": 8, "width": 8, "max_sequence_length": 64, - "output_type": "np", "text_encoder_out_layers": (1,), + # Request torch outputs so tests compare torch tensors directly (see `BasePipelineTesterConfig`). + "output_type": "pt", } return inputs + +class TestFlux2KleinKVPipeline(Flux2KleinKVPipelineTesterConfig, PipelineTesterMixin): def test_fused_qkv_projections(self): - device = "cpu" # ensure determinism for the device-dependent torch.Generator - components = self.get_dummy_components() - pipe = self.pipeline_class(**components) - pipe = pipe.to(device) - pipe.set_progress_bar_config(disable=None) + # Run on CPU to keep the slice comparisons deterministic. + pipe = self.get_pipeline() - inputs = self.get_dummy_inputs(device) + inputs = self.get_dummy_inputs() image = pipe(**inputs).images - original_image_slice = image[0, -3:, -3:, -1] + original_image_slice = image[0, -1, -3:, -3:] pipe.transformer.fuse_qkv_projections() - self.assertTrue( - check_qkv_fused_layers_exist(pipe.transformer, ["to_qkv"]), - ("Something wrong with the fused attention layers. Expected all the attention projections to be fused."), + assert check_qkv_fused_layers_exist(pipe.transformer, ["to_qkv"]), ( + "Something wrong with the fused attention layers. Expected all the attention projections to be fused." ) - inputs = self.get_dummy_inputs(device) + inputs = self.get_dummy_inputs() image = pipe(**inputs).images - image_slice_fused = image[0, -3:, -3:, -1] + image_slice_fused = image[0, -1, -3:, -3:] pipe.transformer.unfuse_qkv_projections() - inputs = self.get_dummy_inputs(device) + inputs = self.get_dummy_inputs() image = pipe(**inputs).images - image_slice_disabled = image[0, -3:, -3:, -1] - - self.assertTrue( - np.allclose(original_image_slice, image_slice_fused, atol=1e-3, rtol=1e-3), - ("Fusion of QKV projections shouldn't affect the outputs."), + image_slice_disabled = image[0, -1, -3:, -3:] + + assert_tensors_close( + original_image_slice, + image_slice_fused, + atol=1e-3, + rtol=1e-3, + msg="Fusion of QKV projections shouldn't affect the outputs.", ) - self.assertTrue( - np.allclose(image_slice_fused, image_slice_disabled, atol=1e-3, rtol=1e-3), - ("Outputs, with QKV projection fusion enabled, shouldn't change when fused QKV projections are disabled."), + assert_tensors_close( + image_slice_fused, + image_slice_disabled, + atol=1e-3, + rtol=1e-3, + msg="Outputs, with QKV projection fusion enabled, shouldn't change when fused QKV projections are disabled.", ) - self.assertTrue( - np.allclose(original_image_slice, image_slice_disabled, atol=1e-2, rtol=1e-2), - ("Original outputs should match when fused QKV projections are disabled."), + assert_tensors_close( + original_image_slice, + image_slice_disabled, + atol=1e-2, + rtol=1e-2, + msg="Original outputs should match when fused QKV projections are disabled.", ) def test_image_output_shape(self): - pipe = self.pipeline_class(**self.get_dummy_components()).to(torch_device) - inputs = self.get_dummy_inputs(torch_device) + pipe = self.get_pipeline().to(torch_device) + inputs = self.get_dummy_inputs() height_width_pairs = [(32, 32), (72, 57)] for height, width in height_width_pairs: @@ -152,21 +154,22 @@ def test_image_output_shape(self): inputs.update({"height": height, "width": width}) image = pipe(**inputs).images[0] - output_height, output_width, _ = image.shape - self.assertEqual( - (output_height, output_width), - (expected_height, expected_width), - f"Output shape {image.shape} does not match expected shape {(expected_height, expected_width)}", + _, output_height, output_width = image.shape + assert (output_height, output_width) == (expected_height, expected_width), ( + f"Output shape {image.shape} does not match expected shape {(expected_height, expected_width)}" ) def test_without_image(self): - device = "cpu" - pipe = self.pipeline_class(**self.get_dummy_components()).to(device) - inputs = self.get_dummy_inputs(device) + pipe = self.get_pipeline().to(torch_device) + inputs = self.get_dummy_inputs() del inputs["image"] image = pipe(**inputs).images - self.assertEqual(image.shape, (1, 8, 8, 3)) + assert image.shape == (1, 3, 8, 8) - @unittest.skip("Needs to be revisited") + @pytest.mark.skip("Needs to be revisited") def test_encode_prompt_works_in_isolation(self): pass + + +class TestFlux2KleinKVPipelineMemory(Flux2KleinKVPipelineTesterConfig, MemoryTesterMixin): + """Memory optimization tests (CPU offload, group offload, layerwise casting) for the Flux2 Klein KV pipeline.""" From 0c0a42b2ca43d9e743c51e9fc7640fff06080c41 Mon Sep 17 00:00:00 2001 From: YiYi Xu Date: Mon, 3 Aug 2026 06:40:38 -1000 Subject: [PATCH 10/24] Auto-reply to new model requests with remote code guidance (#14343) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Auto-reply to new model requests with Modular Diffusers guidance Points new model/pipeline/scheduler requests at building a Hub-hosted modular pipeline instead of waiting on a core PR, and asks requesters to tag @asomoza when they have something to share. Gates on the `### Model/Pipeline/Scheduler description` heading the issue form renders rather than on a label: the label the template declares does not exist in this repo, so GitHub never applies it, and template labels land after issue creation anyway. Co-Authored-By: Claude Opus 5 (1M context) * Apply suggestion from @yiyixuxu * Tighten the new model request reply Lead with starting on the Hub and frame what follows as a support decision (work with the authors, upstream a community version, or point people at the Hub one) rather than a soft no to PRs. Trims the message by collapsing the author section and the resource list. Co-Authored-By: Claude Opus 5 (1M context) * Link the Modular Pipelines collection in the reply Community-built model pipelines belong in the Modular Pipelines collection rather than Custom Blocks, so point the "we'll add the ones we like" line there and keep both collections in the resource list. Co-Authored-By: Claude Opus 5 (1M context) * Recommend starting on the Hub for new models Adds a callout to the contribution guide asking model authors to get in touch on a feature request, and community contributors to coordinate there and start with a Hub repo at the same time. Points agents the same way: default to publishing a modular pipeline as remote code rather than opening a PR against src/diffusers. Co-Authored-By: Claude Opus 5 (1M context) * Apply suggestion from @yiyixuxu * Apply suggestion from @yiyixuxu * Update .github/workflows/new_model_request_reply.yml Co-authored-by: Steven Liu <59462357+stevhliu@users.noreply.github.com> * Consolidate the Hub-first guidance into one place stevhliu noted the new callout in "Adding pipelines, models, schedulers" overlapped the last two paragraphs of the AI-assisted contributions section. Drop the community-contributor paragraph there — it is general contribution policy rather than agent-specific guidance — and point the model-author paragraph at the callout instead. Carry the custom models link over so the non-modular Hub path stays covered. Co-Authored-By: Claude Opus 5 (1M context) --------- Co-authored-by: Claude Opus 5 (1M context) Co-authored-by: Steven Liu <59462357+stevhliu@users.noreply.github.com> --- .github/workflows/new_model_request_reply.yml | 43 +++++++++++++++++++ docs/source/en/conceptual/contribution.md | 9 ++-- 2 files changed, 49 insertions(+), 3 deletions(-) create mode 100644 .github/workflows/new_model_request_reply.yml diff --git a/.github/workflows/new_model_request_reply.yml b/.github/workflows/new_model_request_reply.yml new file mode 100644 index 000000000000..f0c9e4c571af --- /dev/null +++ b/.github/workflows/new_model_request_reply.yml @@ -0,0 +1,43 @@ +name: New Model Request Reply + +on: + issues: + types: [opened] + +jobs: + reply: + name: Point new model requests at Modular Diffusers + # Match the heading the issue form renders for its first field rather than a label: template + # labels are applied after the issue is created, so `github.event.issue.labels` is empty here. + # Keep this string in sync with .github/ISSUE_TEMPLATE/new-model-addition.yml. + if: >- + github.repository == 'huggingface/diffusers' && + contains(github.event.issue.body, '### Model/Pipeline/Scheduler description') + runs-on: ubuntu-latest + permissions: + issues: write + steps: + - name: Post Modular Diffusers guidance + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GH_REPO: ${{ github.repository }} + ISSUE_NUMBER: ${{ github.event.issue.number }} + BODY: | + Thanks for the request! + + **How new model support works in Diffusers** + + We're a small team, and our review queue shouldn't be what decides whether a model is usable in Diffusers. With [Modular Diffusers](https://huggingface.co/docs/diffusers/modular_diffusers/overview), a pipeline can live as remote code in any Hub repo and load straight from there with `from_pretrained`. + + 🛠️ **Want to bring this model to Diffusers?** + + Please start with a Hub repo — you don't need anything from us to do that, and people can use it immediately. From there we decide how to support it: we might work with the authors, upstream an existing community version, or just point people at the one on the Hub. The pipelines we integrate are usually the ones people are already running. + + Tag `@asomoza` when you have something to share — we'll give feedback on the implementation, help get it in front of people, and add the ones we like to our hand-picked [Modular Pipelines](https://huggingface.co/collections/diffusers/modular-pipelines) collection. Tell us where you hit friction along the way, too: confusing APIs, missing docs, bugs. That feedback is worth as much to us as the pipeline. + + 👋 **Are you an author of the model?** We'd love to hear from you — comment here and we'll help you pick the path that fits. + + 📚 [Quickstart](https://huggingface.co/docs/diffusers/modular_diffusers/quickstart) · [Building custom blocks](https://huggingface.co/docs/diffusers/modular_diffusers/custom_blocks) — template repo, and how to publish to the Hub · [Modular Pipelines](https://huggingface.co/collections/diffusers/modular-pipelines) and [Custom Blocks](https://huggingface.co/collections/diffusers/modular-diffusers-custom-blocks) — examples to crib from + + *This is an automated message.* + run: gh issue comment "$ISSUE_NUMBER" --body "$BODY" diff --git a/docs/source/en/conceptual/contribution.md b/docs/source/en/conceptual/contribution.md index 1e60515430b0..2eb3275fa95a 100644 --- a/docs/source/en/conceptual/contribution.md +++ b/docs/source/en/conceptual/contribution.md @@ -332,6 +332,11 @@ Good second issues are usually more difficult to get merged compared to good fir ### 9. Adding pipelines, models, schedulers +> [!TIP] +> If you are the model's author, please get in touch so we can coordinate the integration with you: open a feature request, or drop a comment if one is already open. +> +> If you are a community contributor, please also let us know you're interested under the feature request, and start with a Hub repo at the same time. See the [Modular Diffusers](../modular_diffusers/overview) guide to get started, and [custom blocks](../modular_diffusers/custom_blocks) or [custom models](../using-diffusers/automodel) for publishing as remote code on the Hub. + Pipelines, models, and schedulers are the most important pieces of the Diffusers library. They provide easy access to state-of-the-art diffusion technologies and thus allow the community to build powerful generative AI applications. @@ -605,6 +610,4 @@ AI-assisted contributions are welcome, but they must be coordinated, scoped, and - The **test commands you ran** and their results (paste relevant output, not just "tests pass"). - Your **self-review notes** (or a link to the PR comment containing them), as described above. -If you are a model author or part of a team that officially maintains a model, we encourage you to use agents for a new model integration. Follow the repository's [recommended setup](https://github.com/huggingface/diffusers/blob/main/.ai/AGENTS.md) and use the [`model-integration`](https://github.com/huggingface/diffusers/blob/main/.ai/skills/model-integration/SKILL.md) skill. Coordinate the scope with maintainers before opening a PR. - -If you are contributing a model to Diffusers for the first time as a community contributor, we generally recommend starting with a custom implementation that loads code from the Hub. This gives users access to the model while its integration into the core library is evaluated. See the [custom models](../using-diffusers/automodel) and [custom modular blocks](../modular_diffusers/custom_blocks) guides for supported patterns. +If you are a model author or part of a team that officially maintains a model, we encourage you to use agents for a new model integration. Follow the repository's [recommended setup](https://github.com/huggingface/diffusers/blob/main/.ai/AGENTS.md) and use the [`model-integration`](https://github.com/huggingface/diffusers/blob/main/.ai/skills/model-integration/SKILL.md) skill. Coordinate the scope with maintainers before opening a PR — see [Adding pipelines, models, schedulers](#9-adding-pipelines-models-schedulers). From 775ceb09e62df03095ff3cfee35c7fa53599ac89 Mon Sep 17 00:00:00 2001 From: jiqing-feng Date: Tue, 4 Aug 2026 21:36:15 +0800 Subject: [PATCH 11/24] Add XPU expected slice for `SlowBnb4BitFluxControlWithLoraTests::test_lora_loading` (#14202) * fix xpu slice Signed-off-by: jiqing-feng * complete comment Signed-off-by: jiqing-feng --------- Signed-off-by: jiqing-feng --- tests/quantization/bnb/test_mixed_int8.py | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/tests/quantization/bnb/test_mixed_int8.py b/tests/quantization/bnb/test_mixed_int8.py index a67d26d8cad4..5100e5fc353b 100644 --- a/tests/quantization/bnb/test_mixed_int8.py +++ b/tests/quantization/bnb/test_mixed_int8.py @@ -34,6 +34,7 @@ from ...testing_utils import ( CaptureLogger, + Expectations, backend_empty_cache, is_bitsandbytes_available, is_torch_available, @@ -693,7 +694,16 @@ def test_lora_loading(self): generator=torch.Generator().manual_seed(42), ).images out_slice = output[0, -3:, -3:, -1].flatten() - expected_slice = np.array([0.2029, 0.2136, 0.2268, 0.1921, 0.1997, 0.2185, 0.2021, 0.2183, 0.2292]) + # Hardware-dependent: the Control LoRA dequantizes and expands `x_embedder`, and the error + # accumulates over the 8 denoising steps enough that even different CUDA GPUs disagree, so + # reference slices are stored per accelerator backend. + expected_slices = Expectations( + { + (None, None): np.array([0.2029, 0.2136, 0.2268, 0.1921, 0.1997, 0.2185, 0.2021, 0.2183, 0.2292]), + ("xpu", 5): np.array([0.0955, 0.1223, 0.1509, 0.0872, 0.1155, 0.1890, 0.0754, 0.1028, 0.2178]), + } + ) + expected_slice = expected_slices.get_expectation() max_diff = numpy_cosine_similarity_distance(expected_slice, out_slice) assert max_diff < 1e-3, f"{out_slice=} != {expected_slice=}" From 6648e4b27cdc2a4f7fc5d07d005a84d33ce952d5 Mon Sep 17 00:00:00 2001 From: Sayak Paul Date: Tue, 4 Aug 2026 23:03:21 +0800 Subject: [PATCH 12/24] [tests] Migrate `tests/others` to `pytest`. (#14299) * download kernels when users request for it. * migrate tests/others to pytest * Revert "download kernels when users request for it." This reverts commit 6fe34ade6b6412e694d83a8dd3aee1550a9ebbab. --------- Co-authored-by: dg845 <58458699+dg845@users.noreply.github.com> --- tests/others/test_check_copies.py | 44 +++++++------ tests/others/test_check_dummies.py | 37 +++++------ tests/others/test_check_support_list.py | 23 ++++--- tests/others/test_config.py | 30 ++++----- tests/others/test_ema.py | 47 ++++++-------- tests/others/test_flashpack.py | 56 ++++++++-------- tests/others/test_hub_utils.py | 65 +++++++++---------- tests/others/test_image_processor.py | 4 +- tests/others/test_outputs.py | 11 ++-- tests/others/test_training.py | 24 ++++--- tests/others/test_utils.py | 85 ++++++++++--------------- tests/others/test_video_processor.py | 12 ++-- 12 files changed, 199 insertions(+), 239 deletions(-) diff --git a/tests/others/test_check_copies.py b/tests/others/test_check_copies.py index ec3a5702ea9c..0ae32c1a66f1 100644 --- a/tests/others/test_check_copies.py +++ b/tests/others/test_check_copies.py @@ -16,8 +16,8 @@ import re import shutil import sys -import tempfile -import unittest + +import pytest git_repo_path = os.path.abspath(os.path.dirname(os.path.dirname(os.path.dirname(__file__)))) @@ -45,42 +45,42 @@ """ -class CopyCheckTester(unittest.TestCase): - def setUp(self): - self.diffusers_dir = tempfile.mkdtemp() - os.makedirs(os.path.join(self.diffusers_dir, "schedulers/")) - check_copies.DIFFUSERS_PATH = self.diffusers_dir +class TestCopyCheck: + @pytest.fixture + def diffusers_dir(self, tmp_path, monkeypatch): + """A stand-in `src/diffusers` holding only `scheduling_ddpm.py`, pointed at by `check_copies`.""" + os.makedirs(tmp_path / "schedulers") shutil.copy( os.path.join(git_repo_path, "src/diffusers/schedulers/scheduling_ddpm.py"), - os.path.join(self.diffusers_dir, "schedulers/scheduling_ddpm.py"), + tmp_path / "schedulers" / "scheduling_ddpm.py", ) + monkeypatch.setattr(check_copies, "DIFFUSERS_PATH", str(tmp_path)) + return tmp_path - def tearDown(self): - check_copies.DIFFUSERS_PATH = "src/diffusers" - shutil.rmtree(self.diffusers_dir) - - def check_copy_consistency(self, comment, class_name, class_code, overwrite_result=None): + def check_copy_consistency(self, diffusers_dir, comment, class_name, class_code, overwrite_result=None): code = comment + f"\nclass {class_name}(nn.Module):\n" + class_code if overwrite_result is not None: expected = comment + f"\nclass {class_name}(nn.Module):\n" + overwrite_result code = check_copies.run_ruff(code) - fname = os.path.join(self.diffusers_dir, "new_code.py") + fname = diffusers_dir / "new_code.py" with open(fname, "w", newline="\n") as f: f.write(code) if overwrite_result is None: - self.assertTrue(len(check_copies.is_copy_consistent(fname)) == 0) + assert len(check_copies.is_copy_consistent(fname)) == 0 else: - check_copies.is_copy_consistent(f.name, overwrite=True) + check_copies.is_copy_consistent(fname, overwrite=True) with open(fname, "r") as f: - self.assertTrue(f.read(), expected) + assert f.read() == expected - def test_find_code_in_diffusers(self): + def test_find_code_in_diffusers(self, diffusers_dir): + # `diffusers_dir` is requested for its `DIFFUSERS_PATH` patch — the lookup below resolves against it. code = check_copies.find_code_in_diffusers("schedulers.scheduling_ddpm.DDPMSchedulerOutput") - self.assertEqual(code, REFERENCE_CODE) + assert code == REFERENCE_CODE - def test_is_copy_consistent(self): + def test_is_copy_consistent(self, diffusers_dir): # Base copy consistency self.check_copy_consistency( + diffusers_dir, "# Copied from diffusers.schedulers.scheduling_ddpm.DDPMSchedulerOutput", "DDPMSchedulerOutput", REFERENCE_CODE + "\n", @@ -88,6 +88,7 @@ def test_is_copy_consistent(self): # With no empty line at the end self.check_copy_consistency( + diffusers_dir, "# Copied from diffusers.schedulers.scheduling_ddpm.DDPMSchedulerOutput", "DDPMSchedulerOutput", REFERENCE_CODE, @@ -95,6 +96,7 @@ def test_is_copy_consistent(self): # Copy consistency with rename self.check_copy_consistency( + diffusers_dir, "# Copied from diffusers.schedulers.scheduling_ddpm.DDPMSchedulerOutput with DDPM->Test", "TestSchedulerOutput", re.sub("DDPM", "Test", REFERENCE_CODE), @@ -103,6 +105,7 @@ def test_is_copy_consistent(self): # Copy consistency with a really long name long_class_name = "TestClassWithAReallyLongNameBecauseSomePeopleLikeThatForSomeReason" self.check_copy_consistency( + diffusers_dir, f"# Copied from diffusers.schedulers.scheduling_ddpm.DDPMSchedulerOutput with DDPM->{long_class_name}", f"{long_class_name}SchedulerOutput", re.sub("Bert", long_class_name, REFERENCE_CODE), @@ -110,6 +113,7 @@ def test_is_copy_consistent(self): # Copy consistency with overwrite self.check_copy_consistency( + diffusers_dir, "# Copied from diffusers.schedulers.scheduling_ddpm.DDPMSchedulerOutput with DDPM->Test", "TestSchedulerOutput", REFERENCE_CODE, diff --git a/tests/others/test_check_dummies.py b/tests/others/test_check_dummies.py index b9ddc2764465..c9a3ba3d111d 100644 --- a/tests/others/test_check_dummies.py +++ b/tests/others/test_check_dummies.py @@ -14,7 +14,6 @@ import os import sys -import unittest git_repo_path = os.path.abspath(os.path.dirname(os.path.dirname(os.path.dirname(__file__)))) @@ -28,48 +27,46 @@ check_dummies.PATH_TO_DIFFUSERS = os.path.join(git_repo_path, "src", "diffusers") -class CheckDummiesTester(unittest.TestCase): +class TestCheckDummies: def test_find_backend(self): simple_backend = find_backend(" if not is_torch_available():") - self.assertEqual(simple_backend, "torch") + assert simple_backend == "torch" # backend_with_underscore = find_backend(" if not is_tensorflow_text_available():") - # self.assertEqual(backend_with_underscore, "tensorflow_text") + # assert backend_with_underscore == "tensorflow_text" double_backend = find_backend(" if not (is_torch_available() and is_transformers_available()):") - self.assertEqual(double_backend, "torch_and_transformers") + assert double_backend == "torch_and_transformers" # double_backend_with_underscore = find_backend( # " if not (is_sentencepiece_available() and is_tensorflow_text_available()):" # ) - # self.assertEqual(double_backend_with_underscore, "sentencepiece_and_tensorflow_text") + # assert double_backend_with_underscore == "sentencepiece_and_tensorflow_text" triple_backend = find_backend( " if not (is_torch_available() and is_transformers_available() and is_onnx_available()):" ) - self.assertEqual(triple_backend, "torch_and_transformers_and_onnx") + assert triple_backend == "torch_and_transformers_and_onnx" def test_read_init(self): objects = read_init() # We don't assert on the exact list of keys to allow for smooth grow of backend-specific objects - self.assertIn("torch", objects) - self.assertIn("torch_and_transformers", objects) - self.assertIn("torch_and_transformers_and_onnx", objects) + assert "torch" in objects + assert "torch_and_transformers" in objects + assert "torch_and_transformers_and_onnx" in objects # Likewise, we can't assert on the exact content of a key - self.assertIn("UNet2DModel", objects["torch"]) - self.assertIn("StableDiffusionPipeline", objects["torch_and_transformers"]) - self.assertIn("LMSDiscreteScheduler", objects["torch_and_scipy"]) - self.assertIn("OnnxStableDiffusionPipeline", objects["torch_and_transformers_and_onnx"]) + assert "UNet2DModel" in objects["torch"] + assert "StableDiffusionPipeline" in objects["torch_and_transformers"] + assert "LMSDiscreteScheduler" in objects["torch_and_scipy"] + assert "OnnxStableDiffusionPipeline" in objects["torch_and_transformers_and_onnx"] def test_create_dummy_object(self): dummy_constant = create_dummy_object("CONSTANT", "'torch'") - self.assertEqual(dummy_constant, "\nCONSTANT = None\n") + assert dummy_constant == "\nCONSTANT = None\n" dummy_function = create_dummy_object("function", "'torch'") - self.assertEqual( - dummy_function, "\ndef function(*args, **kwargs):\n requires_backends(function, 'torch')\n" - ) + assert dummy_function == "\ndef function(*args, **kwargs):\n requires_backends(function, 'torch')\n" expected_dummy_class = """ class FakeClass(metaclass=DummyObject): @@ -87,7 +84,7 @@ def from_pretrained(cls, *args, **kwargs): requires_backends(cls, 'torch') """ dummy_class = create_dummy_object("FakeClass", "'torch'") - self.assertEqual(dummy_class, expected_dummy_class) + assert dummy_class == expected_dummy_class def test_create_dummy_files(self): expected_dummy_pytorch_file = """# This file is autogenerated by the command `make fix-copies`, do not edit. @@ -116,4 +113,4 @@ def from_pretrained(cls, *args, **kwargs): requires_backends(cls, ["torch"]) """ dummy_files = create_dummy_files({"torch": ["CONSTANT", "function", "FakeClass"]}) - self.assertEqual(dummy_files["torch"], expected_dummy_pytorch_file) + assert dummy_files["torch"] == expected_dummy_pytorch_file diff --git a/tests/others/test_check_support_list.py b/tests/others/test_check_support_list.py index 0f6b134aad49..a4b460933ef9 100644 --- a/tests/others/test_check_support_list.py +++ b/tests/others/test_check_support_list.py @@ -1,6 +1,5 @@ import os import sys -import unittest from unittest.mock import mock_open, patch @@ -10,10 +9,8 @@ from check_support_list import check_documentation # noqa: E402 -class TestCheckSupportList(unittest.TestCase): - def setUp(self): - # Mock doc and source contents that we can reuse - self.doc_content = """# Documentation +# Mock doc and source contents that we can reuse +DOC_CONTENT = """# Documentation ## FooProcessor [[autodoc]] module.FooProcessor @@ -22,7 +19,7 @@ def setUp(self): [[autodoc]] module.BarProcessor """ - self.source_content = """ +SOURCE_CONTENT = """ class FooProcessor(nn.Module): pass @@ -30,12 +27,14 @@ class BarProcessor(nn.Module): pass """ + +class TestCheckSupportList: def test_check_documentation_all_documented(self): # In this test, both FooProcessor and BarProcessor are documented - with patch("builtins.open", mock_open(read_data=self.doc_content)) as doc_file: + with patch("builtins.open", mock_open(read_data=DOC_CONTENT)) as doc_file: doc_file.side_effect = [ - mock_open(read_data=self.doc_content).return_value, - mock_open(read_data=self.source_content).return_value, + mock_open(read_data=DOC_CONTENT).return_value, + mock_open(read_data=SOURCE_CONTENT).return_value, ] undocumented = check_documentation( @@ -44,7 +43,7 @@ def test_check_documentation_all_documented(self): doc_regex=r"\[\[autodoc\]\]\s([^\n]+)", src_regex=r"class\s+(\w+Processor)\(.*?nn\.Module.*?\):", ) - self.assertEqual(len(undocumented), 0, f"Expected no undocumented classes, got {undocumented}") + assert len(undocumented) == 0, f"Expected no undocumented classes, got {undocumented}" def test_check_documentation_missing_class(self): # In this test, only FooProcessor is documented, but BarProcessor is missing from the docs @@ -56,7 +55,7 @@ def test_check_documentation_missing_class(self): with patch("builtins.open", mock_open(read_data=doc_content_missing)) as doc_file: doc_file.side_effect = [ mock_open(read_data=doc_content_missing).return_value, - mock_open(read_data=self.source_content).return_value, + mock_open(read_data=SOURCE_CONTENT).return_value, ] undocumented = check_documentation( @@ -65,4 +64,4 @@ def test_check_documentation_missing_class(self): doc_regex=r"\[\[autodoc\]\]\s([^\n]+)", src_regex=r"class\s+(\w+Processor)\(.*?nn\.Module.*?\):", ) - self.assertIn("BarProcessor", undocumented, f"BarProcessor should be undocumented, got {undocumented}") + assert "BarProcessor" in undocumented, f"BarProcessor should be undocumented, got {undocumented}" diff --git a/tests/others/test_config.py b/tests/others/test_config.py index 376633601f7e..58567f80f550 100644 --- a/tests/others/test_config.py +++ b/tests/others/test_config.py @@ -14,10 +14,10 @@ # limitations under the License. import json -import tempfile -import unittest from pathlib import Path +import pytest + from diffusers import ( DDIMScheduler, DDPMScheduler, @@ -102,9 +102,9 @@ def __init__(self, test_file_1=Path("foo/bar"), test_file_2=Path("foo bar\\bar") pass -class ConfigTester(unittest.TestCase): +class TestConfig: def test_load_not_from_mixin(self): - with self.assertRaises(ValueError): + with pytest.raises(ValueError): ConfigMixin.load_config("dummy_path") def test_register_to_config(self): @@ -143,7 +143,7 @@ def test_register_to_config(self): assert config["d"] == "for diffusion" assert config["e"] == [1, 3] - def test_save_load(self): + def test_save_load(self, tmp_path): obj = SampleObject() config = obj.config @@ -153,10 +153,9 @@ def test_save_load(self): assert config["d"] == "for diffusion" assert config["e"] == [1, 3] - with tempfile.TemporaryDirectory() as tmpdirname: - obj.save_config(tmpdirname) - new_obj = SampleObject.from_config(SampleObject.load_config(tmpdirname)) - new_config = new_obj.config + obj.save_config(tmp_path) + new_obj = SampleObject.from_config(SampleObject.load_config(tmp_path)) + new_config = new_obj.config # unfreeze configs config = dict(config) @@ -262,7 +261,7 @@ def test_load_dpmsolver(self): # no warning should be thrown assert cap_logger.out == "" - def test_use_default_values(self): + def test_use_default_values(self, tmp_path): # let's first save a config that should be in the form # a=2, # b=5, @@ -277,14 +276,13 @@ def test_use_default_values(self): # make sure that default config has all keys in `_use_default_values` assert set(config_dict.keys()) == set(config.config._use_default_values) - with tempfile.TemporaryDirectory() as tmpdirname: - config.save_config(tmpdirname) + config.save_config(tmp_path) - # now loading it with SampleObject2 should put f into `_use_default_values` - config = SampleObject2.from_config(SampleObject2.load_config(tmpdirname)) + # now loading it with SampleObject2 should put f into `_use_default_values` + config = SampleObject2.from_config(SampleObject2.load_config(tmp_path)) - assert "f" in config.config._use_default_values - assert config.config.f == [1, 3] + assert "f" in config.config._use_default_values + assert config.config.f == [1, 3] # now loading the config, should **NOT** use [1, 3] for `f`, but the default [1, 4] value # **BECAUSE** it is part of `config.config._use_default_values` diff --git a/tests/others/test_ema.py b/tests/others/test_ema.py index 87820ed6af84..05a082902b0d 100644 --- a/tests/others/test_ema.py +++ b/tests/others/test_ema.py @@ -13,9 +13,6 @@ # See the License for the specific language governing permissions and # limitations under the License. -import tempfile -import unittest - import torch from diffusers import UNet2DConditionModel @@ -27,7 +24,7 @@ enable_full_determinism() -class EMAModelTests(unittest.TestCase): +class TestEMAModel: model_id = "hf-internal-testing/tiny-stable-diffusion-pipe" batch_size = 1 prompt_length = 77 @@ -60,15 +57,14 @@ def simulate_backprop(self, unet): unet.load_state_dict(updated_state_dict) return unet - def test_from_pretrained(self): + def test_from_pretrained(self, tmp_path): # Save the model parameters to a temporary directory unet, ema_unet = self.get_models() - with tempfile.TemporaryDirectory() as tmpdir: - ema_unet.save_pretrained(tmpdir) + ema_unet.save_pretrained(tmp_path) - # Load the EMA model from the saved directory - loaded_ema_unet = EMAModel.from_pretrained(tmpdir, model_cls=UNet2DConditionModel, foreach=False) - loaded_ema_unet.to(torch_device) + # Load the EMA model from the saved directory + loaded_ema_unet = EMAModel.from_pretrained(tmp_path, model_cls=UNet2DConditionModel, foreach=False) + loaded_ema_unet.to(torch_device) # Check that the shadow parameters of the loaded model match the original EMA model for original_param, loaded_param in zip(ema_unet.shadow_params, loaded_ema_unet.shadow_params): @@ -164,14 +160,13 @@ def test_zero_decay(self): assert torch.allclose(step_one, step_two) @skip_mps - def test_serialization(self): + def test_serialization(self, tmp_path): unet, ema_unet = self.get_models() noisy_latents, timesteps, encoder_hidden_states = self.get_dummy_inputs() - with tempfile.TemporaryDirectory() as tmpdir: - ema_unet.save_pretrained(tmpdir) - loaded_unet = UNet2DConditionModel.from_pretrained(tmpdir, model_cls=UNet2DConditionModel) - loaded_unet = loaded_unet.to(unet.device) + ema_unet.save_pretrained(tmp_path) + loaded_unet = UNet2DConditionModel.from_pretrained(tmp_path, model_cls=UNet2DConditionModel) + loaded_unet = loaded_unet.to(unet.device) # Since no EMA step has been performed the outputs should match. output = unet(noisy_latents, timesteps, encoder_hidden_states).sample @@ -180,7 +175,7 @@ def test_serialization(self): assert torch.allclose(output, output_loaded, atol=1e-4) -class EMAModelTestsForeach(unittest.TestCase): +class TestEMAModelForeach: model_id = "hf-internal-testing/tiny-stable-diffusion-pipe" batch_size = 1 prompt_length = 77 @@ -215,15 +210,14 @@ def simulate_backprop(self, unet): unet.load_state_dict(updated_state_dict) return unet - def test_from_pretrained(self): + def test_from_pretrained(self, tmp_path): # Save the model parameters to a temporary directory unet, ema_unet = self.get_models() - with tempfile.TemporaryDirectory() as tmpdir: - ema_unet.save_pretrained(tmpdir) + ema_unet.save_pretrained(tmp_path) - # Load the EMA model from the saved directory - loaded_ema_unet = EMAModel.from_pretrained(tmpdir, model_cls=UNet2DConditionModel, foreach=True) - loaded_ema_unet.to(torch_device) + # Load the EMA model from the saved directory + loaded_ema_unet = EMAModel.from_pretrained(tmp_path, model_cls=UNet2DConditionModel, foreach=True) + loaded_ema_unet.to(torch_device) # Check that the shadow parameters of the loaded model match the original EMA model for original_param, loaded_param in zip(ema_unet.shadow_params, loaded_ema_unet.shadow_params): @@ -319,14 +313,13 @@ def test_zero_decay(self): assert torch.allclose(step_one, step_two) @skip_mps - def test_serialization(self): + def test_serialization(self, tmp_path): unet, ema_unet = self.get_models() noisy_latents, timesteps, encoder_hidden_states = self.get_dummy_inputs() - with tempfile.TemporaryDirectory() as tmpdir: - ema_unet.save_pretrained(tmpdir) - loaded_unet = UNet2DConditionModel.from_pretrained(tmpdir, model_cls=UNet2DConditionModel) - loaded_unet = loaded_unet.to(unet.device) + ema_unet.save_pretrained(tmp_path) + loaded_unet = UNet2DConditionModel.from_pretrained(tmp_path, model_cls=UNet2DConditionModel) + loaded_unet = loaded_unet.to(unet.device) # Since no EMA step has been performed the outputs should match. output = unet(noisy_latents, timesteps, encoder_hidden_states).sample diff --git a/tests/others/test_flashpack.py b/tests/others/test_flashpack.py index c14410c0d8e0..c20836df1c98 100644 --- a/tests/others/test_flashpack.py +++ b/tests/others/test_flashpack.py @@ -13,9 +13,7 @@ # See the License for the specific language governing permissions and # limitations under the License. -import pathlib -import tempfile -import unittest +import pytest from diffusers import AutoPipelineForText2Image from diffusers.models.auto_model import AutoModel @@ -27,48 +25,46 @@ import torch -class FlashPackTests(unittest.TestCase): +class TestFlashPack: model_id: str = "hf-internal-testing/tiny-flux-pipe" + # `AutoModel.from_pretrained` builds `_diffusers_load_id` by string-joining the path it is given, + # so the `tmp_path` fixture has to be passed to it as a `str`. + @require_flashpack - def test_save_load_model(self): + def test_save_load_model(self, tmp_path): model = AutoModel.from_pretrained(self.model_id, subfolder="transformer") - with tempfile.TemporaryDirectory() as temp_dir: - model.save_pretrained(temp_dir, use_flashpack=True) - self.assertTrue((pathlib.Path(temp_dir) / "model.flashpack").exists()) - model = AutoModel.from_pretrained(temp_dir, use_flashpack=True) + model.save_pretrained(tmp_path, use_flashpack=True) + assert (tmp_path / "model.flashpack").exists() + model = AutoModel.from_pretrained(str(tmp_path), use_flashpack=True) @require_flashpack - def test_save_load_pipeline(self): + def test_save_load_pipeline(self, tmp_path): pipeline = AutoPipelineForText2Image.from_pretrained(self.model_id) - with tempfile.TemporaryDirectory() as temp_dir: - pipeline.save_pretrained(temp_dir, use_flashpack=True) - self.assertTrue((pathlib.Path(temp_dir) / "transformer" / "model.flashpack").exists()) - self.assertTrue((pathlib.Path(temp_dir) / "vae" / "model.flashpack").exists()) - pipeline = AutoPipelineForText2Image.from_pretrained(temp_dir, use_flashpack=True) + pipeline.save_pretrained(tmp_path, use_flashpack=True) + assert (tmp_path / "transformer" / "model.flashpack").exists() + assert (tmp_path / "vae" / "model.flashpack").exists() + pipeline = AutoPipelineForText2Image.from_pretrained(tmp_path, use_flashpack=True) @require_torch_gpu @require_flashpack - def test_load_model_device_str(self): + def test_load_model_device_str(self, tmp_path): model = AutoModel.from_pretrained(self.model_id, subfolder="transformer") - with tempfile.TemporaryDirectory() as temp_dir: - model.save_pretrained(temp_dir, use_flashpack=True) - model = AutoModel.from_pretrained(temp_dir, use_flashpack=True, device_map={"": "cuda"}) - self.assertTrue(model.device.type == "cuda") + model.save_pretrained(tmp_path, use_flashpack=True) + model = AutoModel.from_pretrained(str(tmp_path), use_flashpack=True, device_map={"": "cuda"}) + assert model.device.type == "cuda" @require_torch_gpu @require_flashpack - def test_load_model_device(self): + def test_load_model_device(self, tmp_path): model = AutoModel.from_pretrained(self.model_id, subfolder="transformer") - with tempfile.TemporaryDirectory() as temp_dir: - model.save_pretrained(temp_dir, use_flashpack=True) - model = AutoModel.from_pretrained(temp_dir, use_flashpack=True, device_map={"": torch.device("cuda")}) - self.assertTrue(model.device.type == "cuda") + model.save_pretrained(tmp_path, use_flashpack=True) + model = AutoModel.from_pretrained(str(tmp_path), use_flashpack=True, device_map={"": torch.device("cuda")}) + assert model.device.type == "cuda" @require_flashpack - def test_load_model_device_auto(self): + def test_load_model_device_auto(self, tmp_path): model = AutoModel.from_pretrained(self.model_id, subfolder="transformer") - with tempfile.TemporaryDirectory() as temp_dir: - model.save_pretrained(temp_dir, use_flashpack=True) - with self.assertRaises(ValueError): - model = AutoModel.from_pretrained(temp_dir, use_flashpack=True, device_map={"": "auto"}) + model.save_pretrained(tmp_path, use_flashpack=True) + with pytest.raises(ValueError): + model = AutoModel.from_pretrained(str(tmp_path), use_flashpack=True, device_map={"": "auto"}) diff --git a/tests/others/test_hub_utils.py b/tests/others/test_hub_utils.py index c897afd26db9..23c48b7f7f5e 100644 --- a/tests/others/test_hub_utils.py +++ b/tests/others/test_hub_utils.py @@ -14,9 +14,8 @@ # limitations under the License. import json import os -import unittest -from pathlib import Path -from tempfile import TemporaryDirectory + +import pytest from diffusers.utils.hub_utils import ( _get_checkpoint_shard_files, @@ -25,17 +24,16 @@ ) -class CreateModelCardTest(unittest.TestCase): - def test_generate_model_card_with_library_name(self): - with TemporaryDirectory() as tmpdir: - file_path = Path(tmpdir) / "README.md" - file_path.write_text("---\nlibrary_name: foo\n---\nContent\n") - model_card = load_or_create_model_card(file_path) - populate_model_card(model_card) - assert model_card.data.library_name == "foo" +class TestCreateModelCard: + def test_generate_model_card_with_library_name(self, tmp_path): + file_path = tmp_path / "README.md" + file_path.write_text("---\nlibrary_name: foo\n---\nContent\n") + model_card = load_or_create_model_card(file_path) + populate_model_card(model_card) + assert model_card.data.library_name == "foo" -class GetCheckpointShardFilesTest(unittest.TestCase): +class TestGetCheckpointShardFiles: def _write_index(self, model_dir, shard_filename): index = {"metadata": {"total_size": 1}, "weight_map": {"w": shard_filename}} index_filename = os.path.join(model_dir, "diffusion_pytorch_model.safetensors.index.json") @@ -43,26 +41,23 @@ def _write_index(self, model_dir, shard_filename): json.dump(index, f) return index_filename - def test_rejects_parent_directory_traversal(self): - with TemporaryDirectory() as tmpdir: - model_dir = os.path.join(tmpdir, "model") - os.makedirs(model_dir) - index_filename = self._write_index(model_dir, "../secret/SECRET.safetensors") - with self.assertRaises(ValueError): - _get_checkpoint_shard_files(model_dir, index_filename) - - def test_rejects_absolute_path(self): - with TemporaryDirectory() as tmpdir: - model_dir = os.path.join(tmpdir, "model") - os.makedirs(model_dir) - index_filename = self._write_index(model_dir, os.path.join(tmpdir, "secret", "SECRET.safetensors")) - with self.assertRaises(ValueError): - _get_checkpoint_shard_files(model_dir, index_filename) - - def test_rejects_subdirectory_component(self): - with TemporaryDirectory() as tmpdir: - model_dir = os.path.join(tmpdir, "model") - os.makedirs(model_dir) - index_filename = self._write_index(model_dir, "sub/shard.safetensors") - with self.assertRaises(ValueError): - _get_checkpoint_shard_files(model_dir, index_filename) + def test_rejects_parent_directory_traversal(self, tmp_path): + model_dir = os.path.join(tmp_path, "model") + os.makedirs(model_dir) + index_filename = self._write_index(model_dir, "../secret/SECRET.safetensors") + with pytest.raises(ValueError): + _get_checkpoint_shard_files(model_dir, index_filename) + + def test_rejects_absolute_path(self, tmp_path): + model_dir = os.path.join(tmp_path, "model") + os.makedirs(model_dir) + index_filename = self._write_index(model_dir, os.path.join(tmp_path, "secret", "SECRET.safetensors")) + with pytest.raises(ValueError): + _get_checkpoint_shard_files(model_dir, index_filename) + + def test_rejects_subdirectory_component(self, tmp_path): + model_dir = os.path.join(tmp_path, "model") + os.makedirs(model_dir) + index_filename = self._write_index(model_dir, "sub/shard.safetensors") + with pytest.raises(ValueError): + _get_checkpoint_shard_files(model_dir, index_filename) diff --git a/tests/others/test_image_processor.py b/tests/others/test_image_processor.py index 88e82ab54b82..0d358699f105 100644 --- a/tests/others/test_image_processor.py +++ b/tests/others/test_image_processor.py @@ -13,8 +13,6 @@ # See the License for the specific language governing permissions and # limitations under the License. -import unittest - import numpy as np import PIL.Image import torch @@ -22,7 +20,7 @@ from diffusers.image_processor import VaeImageProcessor -class ImageProcessorTest(unittest.TestCase): +class TestImageProcessor: @property def dummy_sample(self): batch_size = 1 diff --git a/tests/others/test_outputs.py b/tests/others/test_outputs.py index 90b8bfe9464e..2c1a011523c2 100644 --- a/tests/others/test_outputs.py +++ b/tests/others/test_outputs.py @@ -1,5 +1,4 @@ import pickle as pkl -import unittest from dataclasses import dataclass import numpy as np @@ -15,7 +14,7 @@ class CustomOutput(BaseOutput): images: list[PIL.Image.Image] | np.ndarray -class ConfigTester(unittest.TestCase): +class TestOutputs: def test_outputs_single_attribute(self): outputs = CustomOutput(images=np.random.rand(1, 3, 4, 4)) @@ -80,14 +79,14 @@ def test_torch_pytree(self): data = np.random.rand(1, 3, 4, 4) x = CustomOutput(images=data) - self.assertFalse(torch.utils._pytree._is_leaf(x)) + assert not torch.utils._pytree._is_leaf(x) expected_flat_outs = [data] expected_tree_spec = torch.utils._pytree.TreeSpec(CustomOutput, ["images"], [torch.utils._pytree.LeafSpec()]) actual_flat_outs, actual_tree_spec = torch.utils._pytree.tree_flatten(x) - self.assertEqual(expected_flat_outs, actual_flat_outs) - self.assertEqual(expected_tree_spec, actual_tree_spec) + assert expected_flat_outs == actual_flat_outs + assert expected_tree_spec == actual_tree_spec unflattened_x = torch.utils._pytree.tree_unflatten(actual_flat_outs, actual_tree_spec) - self.assertEqual(x, unflattened_x) + assert x == unflattened_x diff --git a/tests/others/test_training.py b/tests/others/test_training.py index a339ee8a3c6b..1d7aa465863c 100644 --- a/tests/others/test_training.py +++ b/tests/others/test_training.py @@ -13,8 +13,6 @@ # See the License for the specific language governing permissions and # limitations under the License. -import unittest - import torch from diffusers import DDIMScheduler, DDPMScheduler, UNet2DModel @@ -26,7 +24,7 @@ torch.backends.cuda.matmul.allow_tf32 = False -class TrainingTests(unittest.TestCase): +class TestTraining: def get_model_optimizer(self, resolution=32): set_seed(0) model = UNet2DModel(sample_size=resolution, in_channels=3, out_channels=3) @@ -83,8 +81,8 @@ def test_training_step_equality(self): optimizer.step() del model, optimizer - self.assertTrue(torch.allclose(ddpm_noisy_images, ddim_noisy_images, atol=1e-5)) - self.assertTrue(torch.allclose(ddpm_noise_pred, ddim_noise_pred, atol=1e-5)) + assert torch.allclose(ddpm_noisy_images, ddim_noisy_images, atol=1e-5) + assert torch.allclose(ddpm_noise_pred, ddim_noise_pred, atol=1e-5) def test_confidence_aware_loss(self): logits = torch.tensor([[[5.0, 0.0], [0.0, 5.0]]]) @@ -94,8 +92,8 @@ def test_confidence_aware_loss(self): loss, loss_sft, loss_conf = compute_confidence_aware_loss( logits, labels, lambda_conf=0.0, per_token_weights=weights ) - self.assertTrue(torch.allclose(loss, loss_sft)) - self.assertTrue(torch.allclose(loss_conf, torch.zeros_like(loss_conf))) + assert torch.allclose(loss, loss_sft) + assert torch.allclose(loss_conf, torch.zeros_like(loss_conf)) lambda_conf = 0.25 loss, loss_sft, loss_conf = compute_confidence_aware_loss( @@ -118,14 +116,14 @@ def test_confidence_aware_loss(self): ).sum().clamp_min(1) expected = expected_sft + lambda_conf * expected_conf - self.assertTrue(torch.allclose(loss_sft, expected_sft)) - self.assertTrue(torch.allclose(loss_conf, expected_conf)) - self.assertTrue(torch.allclose(loss, expected)) + assert torch.allclose(loss_sft, expected_sft) + assert torch.allclose(loss_conf, expected_conf) + assert torch.allclose(loss, expected) # Temperature affects only the confidence term. loss_t, loss_sft_t, loss_conf_t = compute_confidence_aware_loss( logits, labels, lambda_conf=lambda_conf, temperature=0.5, per_token_weights=weights ) - self.assertTrue(torch.allclose(loss_sft_t, expected_sft)) - self.assertFalse(torch.allclose(loss_conf_t, expected_conf)) - self.assertTrue(torch.allclose(loss_t, loss_sft_t + lambda_conf * loss_conf_t)) + assert torch.allclose(loss_sft_t, expected_sft) + assert not torch.allclose(loss_conf_t, expected_conf) + assert torch.allclose(loss_t, loss_sft_t + lambda_conf * loss_conf_t) diff --git a/tests/others/test_utils.py b/tests/others/test_utils.py index 412c7478c5a7..d1a59cec52f1 100755 --- a/tests/others/test_utils.py +++ b/tests/others/test_utils.py @@ -15,7 +15,6 @@ import importlib import os -import unittest import warnings import pytest @@ -34,19 +33,19 @@ TOKEN = "hf_94wBhPGp6KrrTH3KDchhKpRxZwd6dmHWLL" -class DeprecateTester(unittest.TestCase): +class TestDeprecate: higher_version = ".".join([str(int(__version__.split(".")[0]) + 1)] + __version__.split(".")[1:]) lower_version = "0.0.1" def test_deprecate_function_arg(self): kwargs = {"deprecated_arg": 4} - with self.assertWarns(FutureWarning) as warning: + with pytest.warns(FutureWarning) as warning: output = deprecate("deprecated_arg", self.higher_version, "message", take_from=kwargs) assert output == 4 assert ( - str(warning.warning) + str(warning[0].message) == f"The `deprecated_arg` argument is deprecated and will be removed in version {self.higher_version}." " message" ) @@ -54,19 +53,19 @@ def test_deprecate_function_arg(self): def test_deprecate_function_arg_tuple(self): kwargs = {"deprecated_arg": 4} - with self.assertWarns(FutureWarning) as warning: + with pytest.warns(FutureWarning) as warning: output = deprecate(("deprecated_arg", self.higher_version, "message"), take_from=kwargs) assert output == 4 assert ( - str(warning.warning) + str(warning[0].message) == f"The `deprecated_arg` argument is deprecated and will be removed in version {self.higher_version}." " message" ) def test_deprecate_function_args(self): kwargs = {"deprecated_arg_1": 4, "deprecated_arg_2": 8} - with self.assertWarns(FutureWarning) as warning: + with pytest.warns(FutureWarning) as warning: output_1, output_2 = deprecate( ("deprecated_arg_1", self.higher_version, "Hey"), ("deprecated_arg_2", self.higher_version, "Hey"), @@ -75,47 +74,45 @@ def test_deprecate_function_args(self): assert output_1 == 4 assert output_2 == 8 assert ( - str(warning.warnings[0].message) - == "The `deprecated_arg_1` argument is deprecated and will be removed in version" + str(warning[0].message) == "The `deprecated_arg_1` argument is deprecated and will be removed in version" f" {self.higher_version}. Hey" ) assert ( - str(warning.warnings[1].message) - == "The `deprecated_arg_2` argument is deprecated and will be removed in version" + str(warning[1].message) == "The `deprecated_arg_2` argument is deprecated and will be removed in version" f" {self.higher_version}. Hey" ) def test_deprecate_function_incorrect_arg(self): kwargs = {"deprecated_arg": 4} - with self.assertRaises(TypeError) as error: + with pytest.raises(TypeError) as error: deprecate(("wrong_arg", self.higher_version, "message"), take_from=kwargs) - assert "test_deprecate_function_incorrect_arg in" in str(error.exception) - assert "line" in str(error.exception) - assert "got an unexpected keyword argument `deprecated_arg`" in str(error.exception) + assert "test_deprecate_function_incorrect_arg in" in str(error.value) + assert "line" in str(error.value) + assert "got an unexpected keyword argument `deprecated_arg`" in str(error.value) def test_deprecate_arg_no_kwarg(self): - with self.assertWarns(FutureWarning) as warning: + with pytest.warns(FutureWarning) as warning: deprecate(("deprecated_arg", self.higher_version, "message")) assert ( - str(warning.warning) + str(warning[0].message) == f"`deprecated_arg` is deprecated and will be removed in version {self.higher_version}. message" ) def test_deprecate_args_no_kwarg(self): - with self.assertWarns(FutureWarning) as warning: + with pytest.warns(FutureWarning) as warning: deprecate( ("deprecated_arg_1", self.higher_version, "Hey"), ("deprecated_arg_2", self.higher_version, "Hey"), ) assert ( - str(warning.warnings[0].message) + str(warning[0].message) == f"`deprecated_arg_1` is deprecated and will be removed in version {self.higher_version}. Hey" ) assert ( - str(warning.warnings[1].message) + str(warning[1].message) == f"`deprecated_arg_2` is deprecated and will be removed in version {self.higher_version}. Hey" ) @@ -123,12 +120,12 @@ def test_deprecate_class_obj(self): class Args: arg = 5 - with self.assertWarns(FutureWarning) as warning: + with pytest.warns(FutureWarning) as warning: arg = deprecate(("arg", self.higher_version, "message"), take_from=Args()) assert arg == 5 assert ( - str(warning.warning) + str(warning[0].message) == f"The `arg` attribute is deprecated and will be removed in version {self.higher_version}. message" ) @@ -137,7 +134,7 @@ class Args: arg = 5 foo = 7 - with self.assertWarns(FutureWarning) as warning: + with pytest.warns(FutureWarning) as warning: arg_1, arg_2 = deprecate( ("arg", self.higher_version, "message"), ("foo", self.higher_version, "message"), @@ -148,41 +145,37 @@ class Args: assert arg_1 == 5 assert arg_2 == 7 assert ( - str(warning.warning) + str(warning[0].message) == f"The `arg` attribute is deprecated and will be removed in version {self.higher_version}. message" ) assert ( - str(warning.warnings[0].message) - == f"The `arg` attribute is deprecated and will be removed in version {self.higher_version}. message" - ) - assert ( - str(warning.warnings[1].message) + str(warning[1].message) == f"The `foo` attribute is deprecated and will be removed in version {self.higher_version}. message" ) def test_deprecate_incorrect_version(self): kwargs = {"deprecated_arg": 4} - with self.assertRaises(ValueError) as error: + with pytest.raises(ValueError) as error: deprecate(("wrong_arg", self.lower_version, "message"), take_from=kwargs) assert ( - str(error.exception) + str(error.value) == "The deprecation tuple ('wrong_arg', '0.0.1', 'message') should be removed since diffusers' version" f" {__version__} is >= {self.lower_version}" ) def test_deprecate_incorrect_no_standard_warn(self): - with self.assertWarns(FutureWarning) as warning: + with pytest.warns(FutureWarning) as warning: deprecate(("deprecated_arg", self.higher_version, "This message is better!!!"), standard_warn=False) - assert str(warning.warning) == "This message is better!!!" + assert str(warning[0].message) == "This message is better!!!" def test_deprecate_stacklevel(self): - with self.assertWarns(FutureWarning) as warning: + with pytest.warns(FutureWarning) as warning: deprecate(("deprecated_arg", self.higher_version, "This message is better!!!"), standard_warn=False) - assert str(warning.warning) == "This message is better!!!" - assert "diffusers/tests/others/test_utils.py" in warning.filename + assert str(warning[0].message) == "This message is better!!!" + assert "diffusers/tests/others/test_utils.py" in warning[0].filename def test_deprecate_testing_utils_module(self): import diffusers.utils.testing_utils @@ -204,7 +197,7 @@ def test_deprecate_testing_utils_module(self): ), f"Expected deprecation message substring not found, got: {messages}" -class FourierFilterTester(unittest.TestCase): +class TestFourierFilter: """Tests for :func:`diffusers.utils.torch_utils.fourier_filter` (FreeU helper).""" def _run_without_complexhalf_warning(self, dtype): @@ -247,7 +240,7 @@ def test_fourier_filter_preserves_dtype_and_shape(self): assert out.shape == x.shape -class RandnTensorTester(unittest.TestCase): +class TestRandnTensor: """Tests for :func:`diffusers.utils.torch_utils.randn_tensor`.""" def test_mps_suppresses_cpu_generator_info_log(self): @@ -270,22 +263,14 @@ def _capture(target_device): return cl.out mps_out = _capture("mps") - self.assertNotIn( - "moved to", - mps_out, - f"MPS target should not emit the CPU-fallback info log, got: {mps_out}", - ) + assert "moved to" not in mps_out, f"MPS target should not emit the CPU-fallback info log, got: {mps_out}" cuda_out = _capture("cuda") - self.assertIn( - "moved to", - cuda_out, - f"Non-MPS target should still emit the CPU-fallback info log, got: {cuda_out}", - ) + assert "moved to" in cuda_out, f"Non-MPS target should still emit the CPU-fallback info log, got: {cuda_out}" # Copied from https://github.com/huggingface/transformers/blob/main/tests/utils/test_expectations.py -class ExpectationsTester(unittest.TestCase): +class TestExpectations: def test_expectations(self): expectations = Expectations( { @@ -312,7 +297,7 @@ def check(value, key): check(2, ("cuda", 2)) expectations = Expectations({("cuda", 8): 1}) - with self.assertRaises(ValueError): + with pytest.raises(ValueError): expectations.find_expectation(("xpu", None)) diff --git a/tests/others/test_video_processor.py b/tests/others/test_video_processor.py index 7fd98f26652f..882b3891de89 100644 --- a/tests/others/test_video_processor.py +++ b/tests/others/test_video_processor.py @@ -13,12 +13,10 @@ # See the License for the specific language governing permissions and # limitations under the License. -import unittest - import numpy as np import PIL.Image +import pytest import torch -from parameterized import parameterized from diffusers.video_processor import VideoProcessor @@ -27,7 +25,7 @@ torch.manual_seed(0) -class VideoProcessorTest(unittest.TestCase): +class TestVideoProcessor: def get_dummy_sample(self, input_type): batch_size = 1 num_frames = 5 @@ -128,7 +126,7 @@ def to_np(self, video): return video - @parameterized.expand(["list_images", "list_list_images"]) + @pytest.mark.parametrize("input_type", ["list_images", "list_list_images"]) def test_video_processor_pil(self, input_type): video_processor = VideoProcessor(do_resize=False, do_normalize=True) @@ -140,7 +138,7 @@ def test_video_processor_pil(self, input_type): input_np = self.to_np(input).astype("float32") / 255.0 if output_type != "pil" else self.to_np(input) assert np.abs(input_np - out_np).max() < 1e-6, f"Decoded output does not match input for {output_type=}" - @parameterized.expand(["list_4d_np", "list_5d_np", "5d_np"]) + @pytest.mark.parametrize("input_type", ["list_4d_np", "list_5d_np", "5d_np"]) def test_video_processor_np(self, input_type): video_processor = VideoProcessor(do_resize=False, do_normalize=True) @@ -154,7 +152,7 @@ def test_video_processor_np(self, input_type): ) assert np.abs(input_np - out_np).max() < 1e-6, f"Decoded output does not match input for {output_type=}" - @parameterized.expand(["list_4d_pt", "list_5d_pt", "5d_pt"]) + @pytest.mark.parametrize("input_type", ["list_4d_pt", "list_5d_pt", "5d_pt"]) def test_video_processor_pt(self, input_type): video_processor = VideoProcessor(do_resize=False, do_normalize=True) From 35d01061b0a8a1dea30f8fdbc8bce0302cf44373 Mon Sep 17 00:00:00 2001 From: sashakunitsyn <48119683+sashakunitsyn@users.noreply.github.com> Date: Tue, 4 Aug 2026 18:17:47 +0300 Subject: [PATCH 13/24] [Kandinsky 5] Fix I2V conditioning: don't inject the image latent into visual_cond channels (#14282) Fix Kandinsky 5 I2V: don't duplicate image latent into visual_cond channels Kandinsky5I2VPipeline.prepare_latents injected the conditioning image both as the first latent frame and into the visual_cond channels; the reference implementation (kandinskylab/kandinsky-5) only does the former. The duplicate over-conditions the first frame and produces mesh/visual artifacts. Remove the redundant injection to match the reference. Co-authored-by: Claude Opus 4.8 (1M context) Co-authored-by: Sayak Paul --- src/diffusers/pipelines/kandinsky5/pipeline_kandinsky_i2v.py | 1 - 1 file changed, 1 deletion(-) diff --git a/src/diffusers/pipelines/kandinsky5/pipeline_kandinsky_i2v.py b/src/diffusers/pipelines/kandinsky5/pipeline_kandinsky_i2v.py index e82dc737f1a9..634305daff6b 100644 --- a/src/diffusers/pipelines/kandinsky5/pipeline_kandinsky_i2v.py +++ b/src/diffusers/pipelines/kandinsky5/pipeline_kandinsky_i2v.py @@ -725,7 +725,6 @@ def prepare_latents( ) visual_cond_mask[:, 0:1] = 1 - visual_cond[:, 0:1] = image_latents latents = torch.cat([latents, visual_cond, visual_cond_mask], dim=-1) From 65f8426f4b567fe937662ea41072c43157d51cff Mon Sep 17 00:00:00 2001 From: Akshan Krithick <97239696+akshan-main@users.noreply.github.com> Date: Wed, 5 Aug 2026 07:06:11 -0700 Subject: [PATCH 14/24] use assert_tensors_close in the migrated pipeline tests (#14369) Co-authored-by: Sayak Paul --- tests/pipelines/cogvideo/test_cogvideox.py | 28 +++++++++++++------ .../cogvideo/test_cogvideox_fun_control.py | 28 +++++++++++++------ .../cogvideo/test_cogvideox_image2video.py | 28 +++++++++++++------ .../cogvideo/test_cogvideox_video2video.py | 28 +++++++++++++------ tests/pipelines/qwenimage/test_qwenimage.py | 4 +-- .../qwenimage/test_qwenimage_controlnet.py | 6 ++-- .../qwenimage/test_qwenimage_edit.py | 4 +-- .../qwenimage/test_qwenimage_edit_plus.py | 4 +-- .../qwenimage/test_qwenimage_img2img.py | 4 +-- tests/pipelines/wan/test_wan.py | 2 +- tests/pipelines/wan/test_wan_22.py | 9 ++---- .../wan/test_wan_22_image_to_video.py | 6 ++-- tests/pipelines/wan/test_wan_animate.py | 3 +- .../pipelines/wan/test_wan_image_to_video.py | 4 +-- tests/pipelines/wan/test_wan_vace.py | 6 ++-- .../pipelines/wan/test_wan_video_to_video.py | 3 +- 16 files changed, 107 insertions(+), 60 deletions(-) diff --git a/tests/pipelines/cogvideo/test_cogvideox.py b/tests/pipelines/cogvideo/test_cogvideox.py index e81cd2ffc9a9..efcb03482886 100644 --- a/tests/pipelines/cogvideo/test_cogvideox.py +++ b/tests/pipelines/cogvideo/test_cogvideox.py @@ -14,7 +14,6 @@ import gc -import numpy as np import pytest import torch from transformers import AutoConfig, AutoTokenizer, T5EncoderModel @@ -22,6 +21,7 @@ from diffusers import AutoencoderKLCogVideoX, CogVideoXPipeline, CogVideoXTransformer3DModel, DDIMScheduler from ...testing_utils import ( + assert_tensors_close, backend_empty_cache, numpy_cosine_similarity_distance, require_torch_accelerator, @@ -142,7 +142,7 @@ def test_inference(self): generated_slice = generated_video.flatten() generated_slice = torch.cat([generated_slice[:8], generated_slice[-8:]]) - assert torch.allclose(generated_slice, expected_slice, atol=1e-3) + assert_tensors_close(generated_slice, expected_slice, atol=1e-3) def test_inference_batch_single_identical(self): super().test_inference_batch_single_identical(batch_size=3, expected_max_diff=1e-3) @@ -194,14 +194,26 @@ def test_fused_qkv_projections(self): frames = pipe(**inputs).frames image_slice_disabled = frames[0, -2:, -1, -3:, -3:] - assert np.allclose(original_image_slice, image_slice_fused, atol=1e-3, rtol=1e-3), ( - "Fusion of QKV projections shouldn't affect the outputs." + assert_tensors_close( + original_image_slice, + image_slice_fused, + atol=1e-3, + rtol=1e-3, + msg="Fusion of QKV projections shouldn't affect the outputs.", ) - assert np.allclose(image_slice_fused, image_slice_disabled, atol=1e-3, rtol=1e-3), ( - "Outputs, with QKV projection fusion enabled, shouldn't change when fused QKV projections are disabled." + assert_tensors_close( + image_slice_fused, + image_slice_disabled, + atol=1e-3, + rtol=1e-3, + msg="Outputs, with QKV projection fusion enabled, shouldn't change when fused QKV projections are disabled.", ) - assert np.allclose(original_image_slice, image_slice_disabled, atol=1e-2, rtol=1e-2), ( - "Original outputs should match when fused QKV projections are disabled." + assert_tensors_close( + original_image_slice, + image_slice_disabled, + atol=1e-2, + rtol=1e-2, + msg="Original outputs should match when fused QKV projections are disabled.", ) diff --git a/tests/pipelines/cogvideo/test_cogvideox_fun_control.py b/tests/pipelines/cogvideo/test_cogvideox_fun_control.py index f79c1e71225b..0cae00f6f7a0 100644 --- a/tests/pipelines/cogvideo/test_cogvideox_fun_control.py +++ b/tests/pipelines/cogvideo/test_cogvideox_fun_control.py @@ -13,13 +13,13 @@ # limitations under the License. -import numpy as np import torch from PIL import Image from transformers import AutoConfig, AutoTokenizer, T5EncoderModel from diffusers import AutoencoderKLCogVideoX, CogVideoXFunControlPipeline, CogVideoXTransformer3DModel, DDIMScheduler +from ...testing_utils import assert_tensors_close from ..testing_utils import ( BasePipelineTesterConfig, MemoryTesterMixin, @@ -136,7 +136,7 @@ def test_inference(self): generated_slice = generated_video.flatten() generated_slice = torch.cat([generated_slice[:8], generated_slice[-8:]]) - assert torch.allclose(generated_slice, expected_slice, atol=1e-3) + assert_tensors_close(generated_slice, expected_slice, atol=1e-3) def test_inference_batch_single_identical(self): super().test_inference_batch_single_identical(batch_size=3, expected_max_diff=1e-3) @@ -189,14 +189,26 @@ def test_fused_qkv_projections(self): frames = pipe(**inputs).frames image_slice_disabled = frames[0, -2:, -1, -3:, -3:] - assert np.allclose(original_image_slice, image_slice_fused, atol=1e-3, rtol=1e-3), ( - "Fusion of QKV projections shouldn't affect the outputs." + assert_tensors_close( + original_image_slice, + image_slice_fused, + atol=1e-3, + rtol=1e-3, + msg="Fusion of QKV projections shouldn't affect the outputs.", ) - assert np.allclose(image_slice_fused, image_slice_disabled, atol=1e-3, rtol=1e-3), ( - "Outputs, with QKV projection fusion enabled, shouldn't change when fused QKV projections are disabled." + assert_tensors_close( + image_slice_fused, + image_slice_disabled, + atol=1e-3, + rtol=1e-3, + msg="Outputs, with QKV projection fusion enabled, shouldn't change when fused QKV projections are disabled.", ) - assert np.allclose(original_image_slice, image_slice_disabled, atol=1e-2, rtol=1e-2), ( - "Original outputs should match when fused QKV projections are disabled." + assert_tensors_close( + original_image_slice, + image_slice_disabled, + atol=1e-2, + rtol=1e-2, + msg="Original outputs should match when fused QKV projections are disabled.", ) diff --git a/tests/pipelines/cogvideo/test_cogvideox_image2video.py b/tests/pipelines/cogvideo/test_cogvideox_image2video.py index f00fd30e7928..2ef82d90cba6 100644 --- a/tests/pipelines/cogvideo/test_cogvideox_image2video.py +++ b/tests/pipelines/cogvideo/test_cogvideox_image2video.py @@ -14,7 +14,6 @@ import gc -import numpy as np import pytest import torch from PIL import Image @@ -24,6 +23,7 @@ from diffusers.utils import load_image from ...testing_utils import ( + assert_tensors_close, backend_empty_cache, numpy_cosine_similarity_distance, require_torch_accelerator, @@ -162,7 +162,7 @@ def test_inference(self): generated_slice = generated_video.flatten() generated_slice = torch.cat([generated_slice[:8], generated_slice[-8:]]) - assert torch.allclose(generated_slice, expected_slice, atol=1e-3) + assert_tensors_close(generated_slice, expected_slice, atol=1e-3) def test_inference_batch_single_identical(self): super().test_inference_batch_single_identical(batch_size=3, expected_max_diff=1e-3) @@ -227,14 +227,26 @@ def test_fused_qkv_projections(self): frames = pipe(**inputs).frames image_slice_disabled = frames[0, -2:, -1, -3:, -3:] - assert np.allclose(original_image_slice, image_slice_fused, atol=1e-3, rtol=1e-3), ( - "Fusion of QKV projections shouldn't affect the outputs." + assert_tensors_close( + original_image_slice, + image_slice_fused, + atol=1e-3, + rtol=1e-3, + msg="Fusion of QKV projections shouldn't affect the outputs.", ) - assert np.allclose(image_slice_fused, image_slice_disabled, atol=1e-3, rtol=1e-3), ( - "Outputs, with QKV projection fusion enabled, shouldn't change when fused QKV projections are disabled." + assert_tensors_close( + image_slice_fused, + image_slice_disabled, + atol=1e-3, + rtol=1e-3, + msg="Outputs, with QKV projection fusion enabled, shouldn't change when fused QKV projections are disabled.", ) - assert np.allclose(original_image_slice, image_slice_disabled, atol=1e-2, rtol=1e-2), ( - "Original outputs should match when fused QKV projections are disabled." + assert_tensors_close( + original_image_slice, + image_slice_disabled, + atol=1e-2, + rtol=1e-2, + msg="Original outputs should match when fused QKV projections are disabled.", ) diff --git a/tests/pipelines/cogvideo/test_cogvideox_video2video.py b/tests/pipelines/cogvideo/test_cogvideox_video2video.py index 4ca5ac27d087..c418d8babc02 100644 --- a/tests/pipelines/cogvideo/test_cogvideox_video2video.py +++ b/tests/pipelines/cogvideo/test_cogvideox_video2video.py @@ -13,13 +13,13 @@ # limitations under the License. -import numpy as np import torch from PIL import Image from transformers import AutoConfig, AutoTokenizer, T5EncoderModel from diffusers import AutoencoderKLCogVideoX, CogVideoXTransformer3DModel, CogVideoXVideoToVideoPipeline, DDIMScheduler +from ...testing_utils import assert_tensors_close from ..testing_utils import ( BasePipelineTesterConfig, MemoryTesterMixin, @@ -137,7 +137,7 @@ def test_inference(self): generated_slice = generated_video.flatten() generated_slice = torch.cat([generated_slice[:8], generated_slice[-8:]]) - assert torch.allclose(generated_slice, expected_slice, atol=1e-3) + assert_tensors_close(generated_slice, expected_slice, atol=1e-3) def test_inference_batch_single_identical(self): super().test_inference_batch_single_identical(batch_size=3, expected_max_diff=1e-3) @@ -194,14 +194,26 @@ def test_fused_qkv_projections(self): frames = pipe(**inputs).frames image_slice_disabled = frames[0, -2:, -1, -3:, -3:] - assert np.allclose(original_image_slice, image_slice_fused, atol=1e-3, rtol=1e-3), ( - "Fusion of QKV projections shouldn't affect the outputs." + assert_tensors_close( + original_image_slice, + image_slice_fused, + atol=1e-3, + rtol=1e-3, + msg="Fusion of QKV projections shouldn't affect the outputs.", ) - assert np.allclose(image_slice_fused, image_slice_disabled, atol=1e-3, rtol=1e-3), ( - "Outputs, with QKV projection fusion enabled, shouldn't change when fused QKV projections are disabled." + assert_tensors_close( + image_slice_fused, + image_slice_disabled, + atol=1e-3, + rtol=1e-3, + msg="Outputs, with QKV projection fusion enabled, shouldn't change when fused QKV projections are disabled.", ) - assert np.allclose(original_image_slice, image_slice_disabled, atol=1e-2, rtol=1e-2), ( - "Original outputs should match when fused QKV projections are disabled." + assert_tensors_close( + original_image_slice, + image_slice_disabled, + atol=1e-2, + rtol=1e-2, + msg="Original outputs should match when fused QKV projections are disabled.", ) diff --git a/tests/pipelines/qwenimage/test_qwenimage.py b/tests/pipelines/qwenimage/test_qwenimage.py index 95ad085fd67c..bcc9ec771708 100644 --- a/tests/pipelines/qwenimage/test_qwenimage.py +++ b/tests/pipelines/qwenimage/test_qwenimage.py @@ -22,7 +22,7 @@ QwenImageTransformer2DModel, ) -from ...testing_utils import torch_device +from ...testing_utils import assert_tensors_close, torch_device from ..testing_utils import ( BasePipelineTesterConfig, MemoryTesterMixin, @@ -137,7 +137,7 @@ def test_inference(self): generated_slice = generated_image.flatten() generated_slice = torch.cat([generated_slice[:8], generated_slice[-8:]]) - assert torch.allclose(generated_slice, expected_slice, atol=5e-3) + assert_tensors_close(generated_slice, expected_slice, atol=5e-3) def test_vae_tiling(self, expected_diff_max: float = 0.2): pipe = self.pipeline_class(**self.get_dummy_components()).to(torch_device) diff --git a/tests/pipelines/qwenimage/test_qwenimage_controlnet.py b/tests/pipelines/qwenimage/test_qwenimage_controlnet.py index 70e361ce29a1..949bd5833a7a 100644 --- a/tests/pipelines/qwenimage/test_qwenimage_controlnet.py +++ b/tests/pipelines/qwenimage/test_qwenimage_controlnet.py @@ -25,7 +25,7 @@ ) from diffusers.utils.torch_utils import randn_tensor -from ...testing_utils import torch_device +from ...testing_utils import assert_tensors_close, torch_device from ..testing_utils import ( BasePipelineTesterConfig, MemoryTesterMixin, @@ -173,7 +173,7 @@ def test_qwen_controlnet(self): generated_slice = generated_image.flatten() generated_slice = torch.cat([generated_slice[:8], generated_slice[-8:]]) - assert torch.allclose(generated_slice, expected_slice, atol=5e-3) + assert_tensors_close(generated_slice, expected_slice, atol=5e-3) def test_qwen_controlnet_multicondition(self): # Run on CPU: the expected slice below is CPU-specific. @@ -196,7 +196,7 @@ def test_qwen_controlnet_multicondition(self): generated_slice = generated_image.flatten() generated_slice = torch.cat([generated_slice[:8], generated_slice[-8:]]) - assert torch.allclose(generated_slice, expected_slice, atol=5e-3) + assert_tensors_close(generated_slice, expected_slice, atol=5e-3) def test_vae_tiling(self, expected_diff_max: float = 0.2): pipe = self.pipeline_class(**self.get_dummy_components()).to(torch_device) diff --git a/tests/pipelines/qwenimage/test_qwenimage_edit.py b/tests/pipelines/qwenimage/test_qwenimage_edit.py index c38a4f92af72..3e6c92033169 100644 --- a/tests/pipelines/qwenimage/test_qwenimage_edit.py +++ b/tests/pipelines/qwenimage/test_qwenimage_edit.py @@ -24,7 +24,7 @@ QwenImageTransformer2DModel, ) -from ...testing_utils import torch_device +from ...testing_utils import assert_tensors_close, torch_device from ..testing_utils import ( BasePipelineTesterConfig, MemoryTesterMixin, @@ -142,7 +142,7 @@ def test_inference(self): generated_slice = generated_image.flatten() generated_slice = torch.cat([generated_slice[:8], generated_slice[-8:]]) - assert torch.allclose(generated_slice, expected_slice, atol=5e-3) + assert_tensors_close(generated_slice, expected_slice, atol=5e-3) def test_inference_batch_single_identical(self): super().test_inference_batch_single_identical(batch_size=3, expected_max_diff=1e-1) diff --git a/tests/pipelines/qwenimage/test_qwenimage_edit_plus.py b/tests/pipelines/qwenimage/test_qwenimage_edit_plus.py index 35d75aa75f8f..8680792d6767 100644 --- a/tests/pipelines/qwenimage/test_qwenimage_edit_plus.py +++ b/tests/pipelines/qwenimage/test_qwenimage_edit_plus.py @@ -24,7 +24,7 @@ QwenImageTransformer2DModel, ) -from ...testing_utils import torch_device +from ...testing_utils import assert_tensors_close, torch_device from ..testing_utils import BasePipelineTesterConfig, MemoryTesterMixin, PipelineTesterMixin @@ -139,7 +139,7 @@ def test_inference(self): generated_slice = generated_image.flatten() generated_slice = torch.cat([generated_slice[:8], generated_slice[-8:]]) - assert torch.allclose(generated_slice, expected_slice, atol=1e-3) + assert_tensors_close(generated_slice, expected_slice, atol=1e-3) def test_vae_tiling(self, expected_diff_max: float = 0.2): pipe = self.pipeline_class(**self.get_dummy_components()).to(torch_device) diff --git a/tests/pipelines/qwenimage/test_qwenimage_img2img.py b/tests/pipelines/qwenimage/test_qwenimage_img2img.py index 0b73cf9d2b42..16a2847e730e 100644 --- a/tests/pipelines/qwenimage/test_qwenimage_img2img.py +++ b/tests/pipelines/qwenimage/test_qwenimage_img2img.py @@ -24,7 +24,7 @@ QwenImageTransformer2DModel, ) -from ...testing_utils import floats_tensor, torch_device +from ...testing_utils import assert_tensors_close, floats_tensor, torch_device from ..testing_utils import ( BasePipelineTesterConfig, MemoryTesterMixin, @@ -141,7 +141,7 @@ def test_inference(self): generated_slice = generated_image.flatten() generated_slice = torch.cat([generated_slice[:8], generated_slice[-8:]]) - assert torch.allclose(generated_slice, expected_slice, atol=5e-3) + assert_tensors_close(generated_slice, expected_slice, atol=5e-3) def test_vae_tiling(self, expected_diff_max: float = 0.2): pipe = self.pipeline_class(**self.get_dummy_components()).to(torch_device) diff --git a/tests/pipelines/wan/test_wan.py b/tests/pipelines/wan/test_wan.py index 8ac9a42c3dfb..e1339cf115e1 100644 --- a/tests/pipelines/wan/test_wan.py +++ b/tests/pipelines/wan/test_wan.py @@ -107,7 +107,7 @@ def test_inference(self): generated_slice = generated_video.flatten() generated_slice = torch.cat([generated_slice[:8], generated_slice[-8:]]) - assert torch.allclose(generated_slice, expected_slice, atol=1e-3) + assert_tensors_close(generated_slice, expected_slice, atol=1e-3) def test_save_load_optional_components(self, tmp_path, expected_max_difference=1e-4): # `_optional_components` lists both `transformer` and `transformer_2`, but only `transformer_2` is optional diff --git a/tests/pipelines/wan/test_wan_22.py b/tests/pipelines/wan/test_wan_22.py index 164801e0fe91..8c0d873bf489 100644 --- a/tests/pipelines/wan/test_wan_22.py +++ b/tests/pipelines/wan/test_wan_22.py @@ -123,7 +123,7 @@ def test_inference(self): generated_slice = generated_video.flatten() generated_slice = torch.cat([generated_slice[:8], generated_slice[-8:]]) - assert torch.allclose(generated_slice, expected_slice, atol=1e-3) + assert_tensors_close(generated_slice, expected_slice, atol=1e-3) def test_save_load_optional_components(self, tmp_path, expected_max_difference=1e-4): # For wan 2.2 14B, `transformer` is not used when `boundary_ratio` is 1.0, so only then is it optional. @@ -246,15 +246,12 @@ def test_inference(self): assert generated_video.shape == (9, 3, 32, 32) # fmt: off - expected_slice = torch.tensor([[[0.4814, 0.4298, 0.5094, 0.4289, 0.5061, 0.4301, 0.5043, 0.4284, 0.5375, - 0.5965, 0.5527, 0.6014, 0.5228, 0.6076, 0.6644, 0.5651]]]) + expected_slice = torch.tensor([0.4814, 0.4298, 0.5094, 0.4289, 0.5061, 0.4301, 0.5043, 0.4284, 0.5375, 0.5965, 0.5527, 0.6014, 0.5228, 0.6076, 0.6644, 0.5651]) # fmt: on generated_slice = generated_video.flatten() generated_slice = torch.cat([generated_slice[:8], generated_slice[-8:]]) - assert torch.allclose(generated_slice, expected_slice, atol=1e-3), ( - f"generated_slice: {generated_slice}, expected_slice: {expected_slice}" - ) + assert_tensors_close(generated_slice, expected_slice, atol=1e-3) def test_components_function(self): init_components = self.get_dummy_components() diff --git a/tests/pipelines/wan/test_wan_22_image_to_video.py b/tests/pipelines/wan/test_wan_22_image_to_video.py index 68687889d849..b8967594271b 100644 --- a/tests/pipelines/wan/test_wan_22_image_to_video.py +++ b/tests/pipelines/wan/test_wan_22_image_to_video.py @@ -131,7 +131,7 @@ def test_inference(self): generated_slice = generated_video.flatten() generated_slice = torch.cat([generated_slice[:8], generated_slice[-8:]]) - assert torch.allclose(generated_slice, expected_slice, atol=1e-3) + assert_tensors_close(generated_slice, expected_slice, atol=1e-3) def test_save_load_optional_components(self, tmp_path, expected_max_difference=1e-4): # `_optional_components` lists `transformer`, `transformer_2`, `image_encoder` and `image_processor`. For the @@ -264,12 +264,12 @@ def test_inference(self): assert generated_video.shape == (9, 3, 32, 32) # fmt: off - expected_slice = torch.tensor([[0.4833, 0.4305, 0.5100, 0.4299, 0.5056, 0.4298, 0.5052, 0.4332, 0.5550, 0.6092, 0.5536, 0.5928, 0.5199, 0.5864, 0.6705, 0.5493]]) + expected_slice = torch.tensor([0.4833, 0.4305, 0.5100, 0.4299, 0.5056, 0.4298, 0.5052, 0.4332, 0.5550, 0.6092, 0.5536, 0.5928, 0.5199, 0.5864, 0.6705, 0.5493]) # fmt: on generated_slice = generated_video.flatten() generated_slice = torch.cat([generated_slice[:8], generated_slice[-8:]]) - assert torch.allclose(generated_slice, expected_slice, atol=1e-3) + assert_tensors_close(generated_slice, expected_slice, atol=1e-3) def test_components_function(self): init_components = self.get_dummy_components() diff --git a/tests/pipelines/wan/test_wan_animate.py b/tests/pipelines/wan/test_wan_animate.py index 93dcf7649325..c76606c81a87 100644 --- a/tests/pipelines/wan/test_wan_animate.py +++ b/tests/pipelines/wan/test_wan_animate.py @@ -31,6 +31,7 @@ WanAnimateTransformer3DModel, ) +from ...testing_utils import assert_tensors_close from ..testing_utils import BasePipelineTesterConfig, MemoryTesterMixin, PipelineTesterMixin @@ -159,7 +160,7 @@ def test_inference(self): generated_slice = video.flatten() generated_slice = torch.cat([generated_slice[:8], generated_slice[-8:]]) - assert torch.allclose(generated_slice, expected_slice, atol=1e-3) + assert_tensors_close(generated_slice, expected_slice, atol=1e-3) def test_inference_replacement(self): # Replacement mode with background and mask videos. Run on CPU. diff --git a/tests/pipelines/wan/test_wan_image_to_video.py b/tests/pipelines/wan/test_wan_image_to_video.py index 6feb1a454e7f..0b881f2742bc 100644 --- a/tests/pipelines/wan/test_wan_image_to_video.py +++ b/tests/pipelines/wan/test_wan_image_to_video.py @@ -137,7 +137,7 @@ def test_inference(self): generated_slice = generated_video.flatten() generated_slice = torch.cat([generated_slice[:8], generated_slice[-8:]]) - assert torch.allclose(generated_slice, expected_slice, atol=1e-3) + assert_tensors_close(generated_slice, expected_slice, atol=1e-3) def test_save_load_optional_components(self, tmp_path, expected_max_difference=1e-4): # `_optional_components` lists `transformer`, `transformer_2`, `image_encoder` and `image_processor`, but only @@ -281,7 +281,7 @@ def test_inference(self): generated_slice = generated_video.flatten() generated_slice = torch.cat([generated_slice[:8], generated_slice[-8:]]) - assert torch.allclose(generated_slice, expected_slice, atol=1e-3) + assert_tensors_close(generated_slice, expected_slice, atol=1e-3) def test_save_load_optional_components(self, tmp_path, expected_max_difference=1e-4): # `_optional_components` lists `transformer`, `transformer_2`, `image_encoder` and `image_processor`, but only diff --git a/tests/pipelines/wan/test_wan_vace.py b/tests/pipelines/wan/test_wan_vace.py index e4e5f24be844..c4d62aaec389 100644 --- a/tests/pipelines/wan/test_wan_vace.py +++ b/tests/pipelines/wan/test_wan_vace.py @@ -133,7 +133,7 @@ def test_inference(self): generated_slice = generated_video.flatten() generated_slice = torch.cat([generated_slice[:8], generated_slice[-8:]]) - assert torch.allclose(generated_slice, expected_slice, atol=1e-3) + assert_tensors_close(generated_slice, expected_slice, atol=1e-3) def test_inference_with_single_reference_image(self): # Run on CPU: the expected slice below is CPU-specific. @@ -151,7 +151,7 @@ def test_inference_with_single_reference_image(self): generated_slice = generated_video.flatten() generated_slice = torch.cat([generated_slice[:8], generated_slice[-8:]]) - assert torch.allclose(generated_slice, expected_slice, atol=1e-3) + assert_tensors_close(generated_slice, expected_slice, atol=1e-3) def test_inference_with_multiple_reference_image(self): # Run on CPU: the expected slice below is CPU-specific. @@ -169,7 +169,7 @@ def test_inference_with_multiple_reference_image(self): generated_slice = generated_video.flatten() generated_slice = torch.cat([generated_slice[:8], generated_slice[-8:]]) - assert torch.allclose(generated_slice, expected_slice, atol=1e-3) + assert_tensors_close(generated_slice, expected_slice, atol=1e-3) def test_inference_with_only_transformer(self): components = self.get_dummy_components() diff --git a/tests/pipelines/wan/test_wan_video_to_video.py b/tests/pipelines/wan/test_wan_video_to_video.py index 3b10ae1bba50..92a8d46d6bfc 100644 --- a/tests/pipelines/wan/test_wan_video_to_video.py +++ b/tests/pipelines/wan/test_wan_video_to_video.py @@ -20,6 +20,7 @@ from diffusers import AutoencoderKLWan, UniPCMultistepScheduler, WanTransformer3DModel, WanVideoToVideoPipeline +from ...testing_utils import assert_tensors_close from ..testing_utils import BasePipelineTesterConfig, MemoryTesterMixin, PipelineTesterMixin @@ -107,7 +108,7 @@ def test_inference(self): generated_slice = generated_video.flatten() generated_slice = torch.cat([generated_slice[:8], generated_slice[-8:]]) - assert torch.allclose(generated_slice, expected_slice, atol=1e-3) + assert_tensors_close(generated_slice, expected_slice, atol=1e-3) @pytest.mark.skip( reason="WanVideoToVideoPipeline has to run in mixed precision. Casting the entire pipeline will result in errors" From d224b1cc1484fb1edd976c10f8f6980228ea8aa3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?apolin=C3=A1rio?= Date: Wed, 5 Aug 2026 19:00:38 +0200 Subject: [PATCH 15/24] Add MiniMax-H3 (#14355) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Add MiniMax-H3 * Keep the MiniMax-H3 VAEs in float32 under torch_dtype casts * Make MiniMax-H3 modular only * Assert the VAE float32 pin as a positive contract * Point the modular tests at the hf-internal-testing tiny repo * Document performance recipes per hardware class * Slim hardware loading snippets and PR install note * Use pinnable int8 config and freeze quantized components * Scope load time quantization and disable low cpu mem usage under streamed offload * Minimax h3 follow up (review & refactor) (#14371) review & refactor * Regenerate the modular auto docstrings and restyle the H3 docstrings `make modular-autodoctrings` and `make quality` both failed on CI. The nine `# auto_docstring` blocks in `modular_blocks_minimax_h3.py` were stale, and the docstrings of eleven files had not been through `doc-builder style`. Nothing but docstrings and comments changes here. One trap worth recording: `utils/modular_auto_docstring.py` shells out to `ruff` and degrades silently when it is not on `PATH` ("Warning: tool not found ... Skipping formatting"), which makes it rewrite 42 unrelated pipelines instead of the one that is stale. Co-Authored-By: Claude Opus 5 * Fix the H3 fast tests against the refactored state contract `condition_latents` is the VAE encoder block's own output — a list of one latent per condition — and the after-denoise step unpacks `latents` and drops the conditioning rows, so neither is meaningful once a request has come back. The end-to-end tests no longer reach for them; a new standalone test runs `MiniMaxH3KeyframeVaeEncoderStep` on its own, which is where the list is real. Three other tests were failing for reasons of their own: - `test_check_inputs_references` was missing its `@parametrize` and errored at collection. Added four cases that hit real validation. - The duration ceiling had drifted between the two blocks that check it. `before_denoise` warned and reassigned before validating, so it reported `got 362` — a count the caller never passed, right after warning it had rounded their 346. It now validates first, like `before_encoder` already did, and both messages read `got 346 (rounded up to 362)`. - `height` without `width` raised `TypeError: unsupported operand type(s) for %: 'NoneType' and 'int'` on `t2va`, and died inside the resize on `fl2va`. Both blocks now raise the same error `MiniMaxH3Ref2VASetupStep` already raised. 58 passed -> 71 passed, 5 failed, no errors. What is left: `output_type="latent"` has no opt-out in the video decode block, `reference_image_short_edge` is a constructor argument so it does not survive save/reload, an image reference is PIL-only where the test expects three layouts, and `test_float16_inference`, which fails on the base branch too. Co-Authored-By: Claude Opus 5 * Stop claiming `num_inference_steps` is optional in the H3 tests The block declares it `InputParam.template("num_inference_steps", required=True)` and `expected_workflow_defaults` lists it under `required_inputs` for all three workflows, so `optional_params` said the opposite in the same file. It was inherited from the mixin's default set and never examined. Nothing enforces the requirement today: `_check_inputs` substitutes the declared default before it tests `required`, and the template carries `default=50`, so omitting the input runs 50 steps rather than raising. That is not specific to H3 — 14 call sites on main are in the same position — and it is tracked in #14388. Co-Authored-By: Claude Opus 5 * Fix the two-card recipe, and stop accepting `output_type="latent"` The two-card section did not run, on this branch or on the base one. A pipeline resolves a single execution device for all of its components, so the documented shape — one pipeline whose conditioner is `device_map`ped to the second card and whose denoiser is moved to the first — built the rotary positions on `cuda:1` and handed them to a transformer on `cuda:0`: transformer_minimax_h3.py, in Rope.forward RuntimeError: Expected all tensors to be on the same device, but found at least two devices, cuda:1 and cuda:0! Reproduced against `abc5e9bf7` too, so it is not something the refactor broke; the section went in as a plausible recipe that was never executed. The split is therefore between pipelines rather than inside one: pop the text encoder block into a conditioner of its own, give each pipeline a `ComponentsManager` pinned to a card, and pass the state from one call to the other. The managers' hooks are what align `prompt_embeds` onto the denoiser's card; placing the components by hand instead works too, but then that one tensor has to be moved explicitly. Verified end to end against the tiny fixture. `output_type="latent"` is separate. It is not an output format — a request that wants latents runs a pipeline without the decode blocks — and it used to run the whole VAE decode before dying inside `postprocess_video` with "latent does not exist", which does not say what to do instead. The video decode block now rejects anything it cannot postprocess, before decoding. `test_output_type` covers the three formats it does accept, and the rejection is a `test_check_inputs` case. Co-Authored-By: Claude Opus 5 * Skip the workflow tests whose tiny repositories cannot serve them `test_from_pretrained_workflow`, `test_load_components_workflow` and `test_unload_components` build a pipeline from `pretrained_model_name_or_path` and compare it against the test class's own blocks. Three fixtures cannot answer that, in three different ways, and none of them is about the pipelines' code: - `tiny-anima-modular-pipe` has no `modular_model_index.json`. Anima assembles its dummy components in `get_pipeline` and never loads from a repository, so the path it declares had never been exercised. - `tiny-flux2-klein-modular` names `Flux2KleinBaseAutoBlocks` while its `_class_name` and `is_distilled` both say distilled. `from_pretrained` honours `_blocks_class_name`, so it builds base blocks, which declare a `guider` the distilled ones do not. - `tiny-qwenimage-edit-modular` names `QwenImageModularPipeline`, so `from_pretrained` falls back to `QwenImageAutoBlocks`, which declares no `image_conditioned` workflow. Skipped with a TODO each, since the fixes are Hub-side. The klein and qwenimage ones are a single key in `modular_model_index.json`; Anima needs a repository published. Co-Authored-By: Claude Opus 5 * Declare `reference_image_short_edge` as pipeline config `MiniMaxH3Ref2VASetupStep` resolves three pieces of released-checkpoint geometry — the canvas short edge, the canvas area cap, and the separate short edge an image reference is encoded at — and declared only the first two as `ConfigSpec`s. The third was a constructor argument, so it did not survive a save and reload: the reloaded pipeline rebuilt the block from the repository, took the 2048 default instead of the 64 the tests configure, resized reference images at a different resolution, and landed 0.398 away from the original output. All three now sit together, which is also what lets the test fixture stop reaching into the block tree to swap in a configured copy — the shrunken geometry is one `update_components` call next to the canvas rule it belongs with, and `test_reference_image_geometry` exercises the released default rather than constructing the block with an explicit 2048. Co-Authored-By: Claude Opus 5 * Accept a reference image in the three layouts a video reference takes `MiniMaxH3VideoReference.frames` takes a list of images, a channels-last array or a channels-first tensor; `MiniMaxH3ImageReference.image` took a PIL image only, and the two array layouts failed on `entry.image.size` — an element count on numpy, a bound method on torch — with `TypeError: 'int' object is not subscriptable`. Passing an array through as-is would have been worse than the crash. The resize would silently drop to `F.interpolate`'s nearest-neighbour where a PIL image gets LANCZOS, and the VAE encode does `np.array(image).permute(2, 0, 1)`, which fixes the axes of an image and scrambles those of a channels-first tensor. So the layouts are normalized onto a PIL image before any of that, through the processor component the block already declares: `pt_to_numpy` for the channel axis and `numpy_to_pil` for the array. Neither converts dtype, and `numpy_to_pil` scales by 255, so `uint8` is normalized on the original object first — after `pt_to_numpy` a `uint8` tensor is floats over `[0, 255]` and a later dtype check would miss it. Co-Authored-By: Claude Opus 5 * Read the model's constants off the pipeline rather than the module `before_denoise.py` imported `MINIMAX_H3_AUDIO_CHANNELS`, `MINIMAX_H3_AUDIO_TAG` and `MINIMAX_H3_VIDEO_TAG` and read them inside its layout builders, even though two of the three were already exposed as pipeline properties and used that way everywhere else. They are arguments now, passed from `components.*` at the two `__call__` sites; `audio_tag` needed the property `text_tag` and `video_tag` already had, which is presumably why it was the one still reaching for the global. Same treatment where a helper cannot see a pipeline: `resolve_canvas_size` and `audio_latent_num_frames` take the constants as default arguments instead of closing over them, and `_normalize_video_condition` takes the rate it resamples onto, so `before_encoder.py` imports none of them at all. What is left of the constants is their definitions, the properties that expose them, and default argument values. Two helpers with a single caller each are inlined at it: the two rotary-time sums, which are a parity contract — the reference sums the same series pairwise in one place and sequentially in the other, and the orders differ in the last ulp from 16 latent frames onwards. Both inlined sums are bit-identical to the functions they replace across 1..399 latent frames, and the two still disagree on 373 of those counts. `_frame_position_grid` stays, and `build_packed_sequence` now calls it rather than repeating its five lines. Co-Authored-By: Claude Opus 5 * Reject a reference video too short for the conditioner to read The conditioner reads a reference video at 2 fps and Qwen3-VL merges the sampled frames in groups of two, so anything under 13 frames at 24 fps samples down to a single frame and dies inside transformers: image_processing_glm4v.py ValueError: t:1 must be larger than temporal_factor:2 which names neither the reference nor the rate that produced it, and only fires on the versions carrying that check — it passed locally and failed on CI. `_sample_video_condition_frames` now rejects it where the sampling happens, with the bound derived from the rates rather than hardcoded: "must run at least 13 frames at 24 fps (0.54 seconds), got 4". `test_reference_media_layouts` was building exactly such a reference, four frames, which is what surfaced this. It uses a second of video now, like `test_reference_combinations` already did. Co-Authored-By: Claude Opus 5 * Drop the padded-layout attention mask `forward` built a boolean attention mask whenever a row carried a negative modality tag, reproducing the reference implementation's `cu_seqlens = [0, used, S]` split of the padding tail it adds for FlashAttention. Nothing in this port ever produces such a row: both packers partition `[0, sequence_length)` and write only the text, audio and video tags, so the branch was unreachable and the mask was never built. The `clamp(min=0)` that kept a `-1` from indexing the AdaLN table backwards goes with it. Removing it is not only dead-code removal. `if bool(is_pad.any())` is a data-dependent branch, which has no fullgraph representation, and it was the reason whole-model `fullgraph=True` compilation and `torch.export` were skipped: 4 passed, 1 skipped tests -k "compile or export or aot" Both now run. The model tests go from 40 passed / 4 skipped to 41 passed / 2 skipped, against the same 8 pre-existing memory-offload failures. `attention_mask` stays on the processor, attention and block signatures — it is the signature every other processor in diffusers has, and a custom one may want it — but the model itself never passes anything but `None`, so attention runs unmasked over the one document and every backend stays available. Two of the four documentation examples have been re-run against this and come back bit-identical, frames and audio: `t2va` at the full 768x1344 canvas and `fl2va`. The two `ref2va` ones are still to run. Co-Authored-By: Claude Opus 5 * Apply suggestions from code review Co-authored-by: Steven Liu <59462357+stevhliu@users.noreply.github.com> --------- Co-authored-by: YiYi Xu Co-authored-by: Claude Opus 5 Co-authored-by: Steven Liu <59462357+stevhliu@users.noreply.github.com> --- docs/source/en/_toctree.yml | 10 + .../en/api/models/autoencoderkl_minimax_h3.md | 38 + .../models/autoencoderkl_minimax_h3_audio.md | 36 + .../en/api/models/minimax_h3_transformer3d.md | 41 + docs/source/en/api/pipelines/minimax_h3.md | 352 +++++ docs/source/en/api/schedulers/minimax_h3.md | 23 + .../en/modular_diffusers/modular_pipeline.md | 12 + scripts/convert_minimax_h3_to_diffusers.py | 947 +++++++++++++ src/diffusers/__init__.py | 12 + src/diffusers/models/__init__.py | 6 + src/diffusers/models/autoencoders/__init__.py | 2 + .../autoencoders/autoencoder_kl_minimax_h3.py | 918 ++++++++++++ .../autoencoder_kl_minimax_h3_audio.py | 675 +++++++++ src/diffusers/models/transformers/__init__.py | 1 + .../transformers/transformer_minimax_h3.py | 631 +++++++++ src/diffusers/modular_pipelines/__init__.py | 8 + .../modular_pipelines/minimax_h3/__init__.py | 59 + .../minimax_h3/before_denoise.py | 1247 +++++++++++++++++ .../minimax_h3/before_encoder.py | 518 +++++++ .../modular_pipelines/minimax_h3/decoders.py | 252 ++++ .../modular_pipelines/minimax_h3/denoise.py | 287 ++++ .../modular_pipelines/minimax_h3/encoders.py | 760 ++++++++++ .../minimax_h3/modular_blocks_minimax_h3.py | 790 +++++++++++ .../minimax_h3/modular_pipeline.py | 310 ++++ .../minimax_h3/references.py | 318 +++++ .../modular_pipelines/modular_pipeline.py | 75 +- .../modular_pipeline_utils.py | 14 +- src/diffusers/schedulers/__init__.py | 2 + .../schedulers/scheduling_minimax_h3.py | 283 ++++ src/diffusers/utils/dummy_pt_objects.py | 60 + .../dummy_torch_and_transformers_objects.py | 30 + .../test_models_autoencoder_kl_minimax_h3.py | 156 +++ ..._models_autoencoder_kl_minimax_h3_audio.py | 150 ++ .../test_models_transformer_minimax_h3.py | 182 +++ .../anima/test_modular_pipeline_anima.py | 31 + .../test_modular_pipeline_flux2_klein.py | 16 + .../modular_pipelines/minimax_h3/__init__.py | 0 .../test_modular_pipeline_minimax_h3.py | 861 ++++++++++++ .../qwen/test_modular_pipeline_qwenimage.py | 14 + .../test_modular_pipelines_common.py | 137 ++ 40 files changed, 10256 insertions(+), 8 deletions(-) create mode 100644 docs/source/en/api/models/autoencoderkl_minimax_h3.md create mode 100644 docs/source/en/api/models/autoencoderkl_minimax_h3_audio.md create mode 100644 docs/source/en/api/models/minimax_h3_transformer3d.md create mode 100644 docs/source/en/api/pipelines/minimax_h3.md create mode 100644 docs/source/en/api/schedulers/minimax_h3.md create mode 100644 scripts/convert_minimax_h3_to_diffusers.py create mode 100644 src/diffusers/models/autoencoders/autoencoder_kl_minimax_h3.py create mode 100644 src/diffusers/models/autoencoders/autoencoder_kl_minimax_h3_audio.py create mode 100644 src/diffusers/models/transformers/transformer_minimax_h3.py create mode 100644 src/diffusers/modular_pipelines/minimax_h3/__init__.py create mode 100644 src/diffusers/modular_pipelines/minimax_h3/before_denoise.py create mode 100644 src/diffusers/modular_pipelines/minimax_h3/before_encoder.py create mode 100644 src/diffusers/modular_pipelines/minimax_h3/decoders.py create mode 100644 src/diffusers/modular_pipelines/minimax_h3/denoise.py create mode 100644 src/diffusers/modular_pipelines/minimax_h3/encoders.py create mode 100644 src/diffusers/modular_pipelines/minimax_h3/modular_blocks_minimax_h3.py create mode 100644 src/diffusers/modular_pipelines/minimax_h3/modular_pipeline.py create mode 100644 src/diffusers/modular_pipelines/minimax_h3/references.py create mode 100644 src/diffusers/schedulers/scheduling_minimax_h3.py create mode 100644 tests/models/autoencoders/test_models_autoencoder_kl_minimax_h3.py create mode 100644 tests/models/autoencoders/test_models_autoencoder_kl_minimax_h3_audio.py create mode 100644 tests/models/transformers/test_models_transformer_minimax_h3.py create mode 100644 tests/modular_pipelines/minimax_h3/__init__.py create mode 100644 tests/modular_pipelines/minimax_h3/test_modular_pipeline_minimax_h3.py diff --git a/docs/source/en/_toctree.yml b/docs/source/en/_toctree.yml index 8466cebe2a8c..2d0bf5707ad9 100644 --- a/docs/source/en/_toctree.yml +++ b/docs/source/en/_toctree.yml @@ -375,6 +375,8 @@ title: Lumina2Transformer2DModel - local: api/models/lumina_nextdit2d title: LuminaNextDiT2DModel + - local: api/models/minimax_h3_transformer3d + title: MiniMaxH3Transformer3DModel - local: api/models/mochi_transformer3d title: MochiTransformer3DModel - local: api/models/motif_video_transformer_3d @@ -459,6 +461,10 @@ title: AutoencoderKLLTXVideo - local: api/models/autoencoderkl_magvit title: AutoencoderKLMagvit + - local: api/models/autoencoderkl_minimax_h3 + title: AutoencoderKLMiniMaxH3 + - local: api/models/autoencoderkl_minimax_h3_audio + title: AutoencoderKLMiniMaxH3Audio - local: api/models/autoencoderkl_mochi title: AutoencoderKLMochi - local: api/models/autoencoderkl_qwenimage @@ -691,6 +697,8 @@ title: LTX-2 - local: api/pipelines/ltx_video title: LTXVideo + - local: api/pipelines/minimax_h3 + title: MiniMax-H3 - local: api/pipelines/mochi title: Mochi - local: api/pipelines/motif_video @@ -770,6 +778,8 @@ title: LCMScheduler - local: api/schedulers/lms_discrete title: LMSDiscreteScheduler + - local: api/schedulers/minimax_h3 + title: MiniMaxH3Scheduler - local: api/schedulers/pndm title: PNDMScheduler - local: api/schedulers/repaint diff --git a/docs/source/en/api/models/autoencoderkl_minimax_h3.md b/docs/source/en/api/models/autoencoderkl_minimax_h3.md new file mode 100644 index 000000000000..97220ee8c04c --- /dev/null +++ b/docs/source/en/api/models/autoencoderkl_minimax_h3.md @@ -0,0 +1,38 @@ + + +# AutoencoderKLMiniMaxH3 + +The video variational autoencoder (VAE) model with KL loss used in [MiniMax-H3](https://huggingface.co/MiniMaxAI/MiniMax-H3) by MiniMax. It pairs a causal 3D CNN encoder with a non-causal ViT decoder and compresses 16x spatially and 4x temporally. + +Three things set it apart from most autoencoders in the library: + +- **Latents are normalized per channel.** There is no `scaling_factor`: a pipeline encodes with `(latent - latents_mean) / latents_std` and decodes with `latent * latents_std + latents_mean`. +- **The pixel convention is ImageNet-normalized RGB over a `[0, 1]` base range**, not the usual `[-1, 1]`. `encode` expects `(pixel - imagenet_mean) / imagenet_std` and `decode` returns values in that same space, so a pipeline applies `sample * imagenet_std + imagenet_mean` and clamps to `[0, 1]` before postprocessing. +- **Spatial tiling is on by default.** MiniMax-H3 was released with tiling enabled for both encoding and decoding and the released frames are the blended-tile ones, so turning it off changes the output. Use `enable_tiling` to change the tile geometry and `disable_tiling` to switch it off. + +The temporal geometry is fixed by `clip_length` (17 pixel frames per encoder chunk) and `token_drop` (3 trailing latent frames dropped per encode), so `17 * n + 5` pixel frames map to `5 * n + 2` latent frames. + +```python +import torch +from diffusers import AutoencoderKLMiniMaxH3 + +vae = AutoencoderKLMiniMaxH3.from_pretrained( + "MiniMaxAI/MiniMax-H3", subfolder="vae", dtype=torch.float32 +).to("cuda") +``` + +## AutoencoderKLMiniMaxH3 + +[[autodoc]] AutoencoderKLMiniMaxH3 + - encode + - decode + - all diff --git a/docs/source/en/api/models/autoencoderkl_minimax_h3_audio.md b/docs/source/en/api/models/autoencoderkl_minimax_h3_audio.md new file mode 100644 index 000000000000..ab78da3f5e32 --- /dev/null +++ b/docs/source/en/api/models/autoencoderkl_minimax_h3_audio.md @@ -0,0 +1,36 @@ + + +# AutoencoderKLMiniMaxH3Audio + +The audio autoencoder used in [MiniMax-H3](https://huggingface.co/MiniMaxAI) by MiniMax. It is waveform in and waveform out, with no mel front-end and no separate vocoder: a DAC-lineage strided convolutional encoder, a causal-attention projection onto the diffusion latent width, and a BigVGAN decoder. + +The encoder hops 800 samples at 32 kHz, i.e. 40 latents per second, so a waveform of `800 * n` samples encodes to `n` latents. Waveforms that are not a whole number of hops are right-padded. + +The causal-attention projection goes through the attention dispatcher, so `set_attention_backend` applies to it; its mask is `is_causal=True`, which every backend honours except `_native_npu`, whose kernel takes no causal flag. + +The autoencoder is **mono**, and it normalizes latents per channel with `latents_mean` / `latents_std` rather than a scalar `scaling_factor`. MiniMax-H3 carries stereo as two *batch* items, and it always consumes the posterior mean (`latent_dist.mode()`), never a sample. + +```python +import torch +from diffusers import AutoencoderKLMiniMaxH3Audio + +audio_vae = AutoencoderKLMiniMaxH3Audio.from_pretrained( + "MiniMaxAI/MiniMax-H3", subfolder="audio_vae", dtype=torch.float32 +).to("cuda") +``` + +## AutoencoderKLMiniMaxH3Audio + +[[autodoc]] AutoencoderKLMiniMaxH3Audio + - encode + - decode + - all diff --git a/docs/source/en/api/models/minimax_h3_transformer3d.md b/docs/source/en/api/models/minimax_h3_transformer3d.md new file mode 100644 index 000000000000..423e8fdc5837 --- /dev/null +++ b/docs/source/en/api/models/minimax_h3_transformer3d.md @@ -0,0 +1,41 @@ + + +# MiniMaxH3Transformer3DModel + +A Diffusion Transformer model for joint video and audio generation, introduced in [MiniMax-H3](https://huggingface.co/MiniMaxAI/MiniMax-H3) by MiniMax. + +MiniMax-H3 runs a single stack of blocks over **one packed 1-D sequence** that holds the text conditioning, the conditioning image and video rows, the audio rows and the target video rows at once. Attention is full self-attention over that sequence, so there is no cross-attention and no per-modality block weights. Modality-specific behaviour comes only from the two input patch projections, the per-row modality tag that selects the AdaLN modulation parameters, and the two output heads. + +Building the packed layout is the caller's job, which is why the forward signature takes the layout apart from the latents: the `(t, h, w)` position grid, the per-row modality tags, the per-row timestep indices and the three index tensors that address the video, audio and text rows. [`MiniMaxH3Blocks`] and [`MiniMaxH3Ref2VABlocks`] build all of it. + +A layout that carries padding rows (tag `-1`) needs a masked attention backend, since those rows are kept in their own attention document by a boolean mask; a padless sequence needs no mask and keeps every backend available. + +One repository holds both released checkpoint partitions, so the subfolder is what selects the task: `transformer/` for the text and keyframe tasks, `transformer_ref/` for the omni-reference task. + +```python +import torch +from diffusers import MiniMaxH3Transformer3DModel + +transformer = MiniMaxH3Transformer3DModel.from_pretrained( + "MiniMaxAI/MiniMax-H3", subfolder="transformer", dtype=torch.bfloat16 +).to("cuda") +``` + +The checkpoint is mixed precision: the two input patch projections, the timestep MLP and the two output heads are float32 while the block stack is bfloat16. `from_pretrained` keeps that layout through `_keep_in_fp32_modules`, so pass `dtype=torch.bfloat16` and let it place the float32 modules rather than casting the model with `.to(torch.bfloat16)` afterwards. + +## MiniMaxH3Transformer3DModel + +[[autodoc]] MiniMaxH3Transformer3DModel + +## MiniMaxH3TransformerOutput + +[[autodoc]] models.transformers.transformer_minimax_h3.MiniMaxH3TransformerOutput diff --git a/docs/source/en/api/pipelines/minimax_h3.md b/docs/source/en/api/pipelines/minimax_h3.md new file mode 100644 index 000000000000..5e027004b825 --- /dev/null +++ b/docs/source/en/api/pipelines/minimax_h3.md @@ -0,0 +1,352 @@ + + +# MiniMax-H3 + + +> [!TIP] +> MiniMax-H3 is not part of a diffusers release yet. Install diffusers from the pull request to use it: +> `pip install git+https://github.com/huggingface/diffusers.git@refs/pull/14355/head` + + +MiniMax-H3 generates video and its soundtrack together. A single transformer denoises one packed sequence containing the text conditioning, conditioning media, and target video and audio latents. There is no separate vocoder and no audio post-hoc pass: video and audio come out of the same denoising loop. + +You can find the original MiniMax-H3 checkpoints under the [MiniMaxAI](https://huggingface.co/MiniMaxAI) organization. + +MiniMax-H3 is integrated as [Modular Diffusers](../../modular_diffusers/overview) blocks only, the way [Anima](./anima) is: the blocks and their [`MiniMaxH3ModularPipeline`] are the whole integration, and there is no `DiffusionPipeline` half. + +## Checkpoint layout + +MiniMax-H3 was released as two checkpoint partitions that share every component except the transformer, so the diffusers conversion puts both in **one repository**: + +| Subfolder | Workflows | +|---|---| +| `transformer/` | `t2va` (text only), `fl2va` (first and/or last keyframe) | +| `transformer_ref/` | `ref2va` (an ordered mix of image, video and audio references) | + +Everything but the transformer, i.e. the video VAE, the audio VAE, the Qwen3-VL conditioner, its tokenizer and processor, and the two schedulers, is shared and stored once. + +The conditioner is a `Qwen3VLForConditionalGeneration`, and MiniMax-H3 reads the *unnormalized* hidden state after its 50th decoder layer rather than the last one, so the full released checkpoint is used with its language-model head unused. + +All three tasks are workflows of the one [`MiniMaxH3Blocks`], and the repository carries one `modular_model_index.json` naming every component with its own loading spec. To serve a single task, pass the workflow to `from_pretrained`: it keeps only that workflow's blocks, so the pipeline's signature (`pipe.doc`) documents exactly that task's inputs, only that task's components are declared, and `load_components` fetches exactly their subfolders — a `t2va` / `fl2va` pipeline never touches `transformer_ref/`, a `ref2va` one never touches `transformer/`. + +```py +import torch +from diffusers import ModularPipeline + +pipe = ModularPipeline.from_pretrained("MiniMaxAI/MiniMax-H3", workflow="ref2va") +pipe.load_components(dtype=torch.bfloat16) +``` + +> [!TIP] +> `pipe.doc` prints what the pipeline in front of you takes and returns — every input with its default, the components it expects and the outputs it produces. Pruned to one workflow it describes exactly that task, which is the quickest way to see what a request needs before making one. + +To keep every workflow available on one pipeline instead, leave the `workflow` argument out: the pipeline then picks the workflow per call from the inputs it is passed. + +```py +pipe = ModularPipeline.from_pretrained("MiniMaxAI/MiniMax-H3") +``` + +The *loading* can still go one workflow at a time. This one call fetches `transformer/` and every shared component, which serves both `t2va` and `fl2va`: + +```py +pipe.load_components(workflow="t2va", dtype=torch.bfloat16) +``` + +A plain `load_components()` with no `workflow=` pulls **both** 61.7GB transformer partitions, which is what lets one pipeline serve all three workflows without another loading call. Pair it with a [`ComponentsManager`] and auto offloading: the weights live in host RAM and the manager moves onto the accelerator just what each step needs, so when the `ref2va` denoiser wants the device the strategy offloads whatever frees enough room. See [Memory](#memory) for the recipes. + +## Two schedulers + +Video and audio latents step down two different schedules inside a single transformer call per step, which is why the blocks expect two [`MiniMaxH3Scheduler`] instances: `scheduler` for the video latents (`shift=12.0` in the released checkpoints) and `audio_scheduler` for the audio latents (`shift=3.0`). + +Both transformer partitions are guidance-distilled, so this holds for every workflow: guidance is baked into the weights, there is no guider, no `negative_prompt` and no `guidance_scale`, and every step runs exactly one forward pass. + +## Generation constraints + +- **24 fps, 5 to 15 seconds.** `num_frames` is snapped up to the next `17 * n + 5` the video VAE can decode, and the resulting duration has to stay in that window. +- **A 768 pixel short edge.** `height` and `width` default to MiniMax-H3's own canvas for the aspect ratio of the first keyframe (or 16:9 without one) and must be multiples of 32. +- **One generator, three draws.** A request draws the keyframe or reference conditioning noise first, then the video noise, then the audio noise, all from the `generator` it is passed, so two runs from the same generator state return the same video and soundtrack. Passing `latents` or `audio_latents` replaces the corresponding draw. +- **`num_inference_steps` counts sigma grid points**, the terminal `0` included, so it drives one model evaluation less. + +## Memory + +The transformer alone is 61.7 GB in bfloat16 and the Qwen3-VL conditioner is another 62.1 GB, so the loading recipe depends on the hardware. Smaller canvases are the biggest speed lever on every setup: `height` and `width` only have to be multiples of 32, and 960x544 runs about 2.3x faster per step than the trained 1344x768. + +On one 80 GB card, register the components in a [`ComponentsManager`] and let it move them on and off the accelerator: + +```py +import torch +from diffusers import ComponentsManager, ModularPipeline + +manager = ComponentsManager() +pipe = ModularPipeline.from_pretrained("MiniMaxAI/MiniMax-H3", components_manager=manager) +pipe.load_components(workflow="t2va", dtype=torch.bfloat16) +manager.enable_auto_cpu_offload(device="cuda", memory_reserve_margin="12GB") +pipe.transformer.set_attention_backend("_flash_3_hub") # Hopper, roughly 3x faster; kernels fetched from the Hub +``` + +On a consumer card (24 to 32 GB), quantize the two large components to int8 as they load and stream the transformer's blocks from CPU RAM. Everything below uses supported loaders only, no patches, and works straight from the bfloat16 checkpoint: + +```py +import torch +from diffusers import MiniMaxH3Transformer3DModel, ModularPipeline, TorchAoConfig +from diffusers.hooks import apply_group_offloading +from transformers import Qwen3VLForConditionalGeneration +from transformers import TorchAoConfig as TransformersTorchAoConfig +from torchao.quantization import Int8WeightOnlyConfig + +pipe = ModularPipeline.from_pretrained("MiniMaxAI/MiniMax-H3") +pipe.update_components( + transformer=MiniMaxH3Transformer3DModel.from_pretrained( + "MiniMaxAI/MiniMax-H3", subfolder="transformer", dtype=torch.bfloat16, + quantization_config=TorchAoConfig( + Int8WeightOnlyConfig(version=2), + modules_to_not_convert=[ + "proj_in", "audio_proj_in", "context_embedder", "time_embedder", "time_proj", + "token_refiner", "norm_out", "proj_out", "audio_proj_out", + ], + ), + low_cpu_mem_usage=False, + ), + text_encoder=Qwen3VLForConditionalGeneration.from_pretrained( + "MiniMaxAI/MiniMax-H3", subfolder="text_encoder", dtype=torch.bfloat16, + quantization_config=TransformersTorchAoConfig( + Int8WeightOnlyConfig(version=2), + modules_to_not_convert=["model.visual", "model.language_model.embed_tokens", "model.language_model.norm", "lm_head"], + ), + ), +) +pipe.load_components(workflow="t2va", dtype=torch.bfloat16) + +# version=2 int8 tensors are pinnable, which streamed offload needs, and freezing removes the one autograd +# path the quantized tensors cannot serve. +pipe.transformer.requires_grad_(False) +pipe.text_encoder.requires_grad_(False) + +offload = dict(onload_device=torch.device("cuda"), offload_device=torch.device("cpu"), use_stream=True) +pipe.transformer.enable_group_offload(offload_type="block_level", num_blocks_per_group=1, **offload) +apply_group_offloading(pipe.text_encoder.model, offload_type="leaf_level", **offload) +pipe.vae.to("cuda") +pipe.audio_vae.to("cuda") +``` + +On 12 to 16 GB the same recipe works with the video VAE group offloaded too (`offload_type="leaf_level"`, no stream) and a small canvas such as 960x544. Expect the weights to live in host RAM: around 75 GB of it at int8. + +With two cards nothing has to be offloaded: split the pipeline in two and put each half on its own device. + +```py +import torch +from diffusers import ComponentsManager, ModularPipeline + +workflow = ModularPipeline.from_pretrained("MiniMaxAI/MiniMax-H3").blocks.get_workflow("t2va") + +text_manager = ComponentsManager() +text_manager.enable_auto_cpu_offload(device="cuda:1") +conditioner = workflow.sub_blocks.pop("text_encoder").init_pipeline( + "MiniMaxAI/MiniMax-H3", components_manager=text_manager +) +conditioner.load_components(dtype=torch.bfloat16) + +manager = ComponentsManager() +manager.enable_auto_cpu_offload(device="cuda:0") +rest = workflow.init_pipeline("MiniMaxAI/MiniMax-H3", components_manager=manager) +rest.load_components(dtype=torch.bfloat16) + +prompt = "A red fox trotting through a snowy pine forest, snow crunching underfoot" +state = conditioner(prompt=prompt) +results = rest( + state=state, + num_frames=124, + generator=torch.Generator().manual_seed(42), + output=["videos", "audio", "sampling_rate"], +) +``` + +Two 80 GB cards run full bfloat16 this way: each half fits on its own card, so nothing is evicted back to host memory once it is resident. Two 48 GB cards do the same with the int8 loading above on both components. + +## Text and keyframes + +[`MiniMaxH3Blocks`] covers text-to-video-and-audio and keyframe conditioning. A keyframe can be the frame the video starts from (`image`), the frame it ends on (`last_image`), or both. + +```py +import torch +from diffusers import ComponentsManager, ModularPipeline +from diffusers.utils import load_image +from diffusers.utils.export_utils import encode_video + +# 61.7GB of transformer and 62.1GB of conditioner do not sit on one accelerator, so the components are +# registered in a manager that moves each one on and off as the blocks reach it. See [Memory](#memory). +manager = ComponentsManager() +manager.enable_auto_cpu_offload(device="cuda") + +pipe = ModularPipeline.from_pretrained("MiniMaxAI/MiniMax-H3", components_manager=manager) +pipe.load_components(workflow="fl2va", dtype=torch.bfloat16) + +prompt = "A red fox trotting through a snowy pine forest, snow crunching underfoot" +# `output=` returns exactly the named outputs instead of the whole pipeline state. +outputs = ["videos", "audio", "sampling_rate"] + +# Text to video + audio. +results = pipe(prompt=prompt, num_frames=124, generator=torch.Generator().manual_seed(42), output=outputs) + +# First frame (and optionally last frame) to video + audio. The canvas follows the first keyframe. +image = load_image( + "https://huggingface.co/datasets/huggingface/documentation-images/resolve/main/diffusers/astronaut.jpg" +) +results = pipe(prompt=prompt, image=image, num_frames=124, generator=torch.Generator().manual_seed(42), output=outputs) + +encode_video( + results["videos"][0], + fps=24, + output_path="minimax_h3_fl2va.mp4", + audio=results["audio"][0], + audio_sample_rate=results["sampling_rate"], +) +``` + +Video and audio are generated jointly and come out of the call as separate outputs, `videos` and `audio`, next to the `sampling_rate` the soundtrack carries; muxing them into one file is left to the caller, e.g. with [`~utils.export_utils.encode_video`]. + +## Omni-references + +The `ref2va` workflow conditions on an ordered list of references: up to 9 images, 3 videos and 3 audio clips, 12 in total. The order is semantic. It labels the references in the prompt presentation (`""`, `"