diff --git a/README.md b/README.md index f0fedb7d9..a6126aeaf 100644 --- a/README.md +++ b/README.md @@ -15,6 +15,7 @@ API and command-line option may change frequently.*** ## 🔥Important News +* **2026/08/04** 🚀 stable-diffusion.cpp adds **Day-1 support for MiniMax-H3** * **2026/06/25** 🚀 stable-diffusion.cpp now supports **Krea2** * **2026/06/04** 🚀 stable-diffusion.cpp now supports **Ideogram4** * **2026/05/31** 🚀 stable-diffusion.cpp now supports **PiD** @@ -66,6 +67,7 @@ API and command-line option may change frequently.*** - [Mage-Flow-Edit](./docs/mage_flow.md#image-editing) - Video Models - [Wan2.1/Wan2.2](./docs/wan.md) + - [MiniMax-H3](./docs/minimax_h3.md) - [LTX-2.3](./docs/ltx2.md) - [HunyuanVideo 1.5](./docs/hunyuan_video.md) - [LingBot-Video](./docs/lingbot_video.md) diff --git a/assets/minimax-h3/i2av.mp4 b/assets/minimax-h3/i2av.mp4 new file mode 100644 index 000000000..533c9cbfa Binary files /dev/null and b/assets/minimax-h3/i2av.mp4 differ diff --git a/assets/minimax-h3/r2av.mp4 b/assets/minimax-h3/r2av.mp4 new file mode 100644 index 000000000..29b496dba Binary files /dev/null and b/assets/minimax-h3/r2av.mp4 differ diff --git a/assets/minimax-h3/t2av.mp4 b/assets/minimax-h3/t2av.mp4 new file mode 100644 index 000000000..4694e5fd3 Binary files /dev/null and b/assets/minimax-h3/t2av.mp4 differ diff --git a/docs/minimax_h3.md b/docs/minimax_h3.md new file mode 100644 index 000000000..82d0e2ffb --- /dev/null +++ b/docs/minimax_h3.md @@ -0,0 +1,96 @@ +# MiniMax-H3 + +MiniMax-H3 jointly generates video and stereo audio with a packed diffusion +transformer. The implementation supports text-to-audio-video (T2VA), optional +first-frame conditioning (I2VA), first/last-frame conditioning (FL2VA), and +image/video/audio reference conditioning (Ref2VA). + +## Model files + +Pass the four MiniMax-H3 components separately: + +- `--diffusion-model`: MiniMax-H3 diffusion transformer +- `--vae`: MiniMax-H3 video VAE +- `--audio-vae`: MiniMax-H3 audio VAE +- `--llm`: the MiniMax-H3 Qwen3-VL-32B text encoder checkpoint + +The text encoder must be the MiniMax-H3 variant: Qwen3-VL-32B truncated to 50 +language layers and exported without the final language-model normalization. +Its Qwen3-VL vision tower, including the three DeepStack mergers, must also be +present. If the vision tower is stored separately, pass it with `--llm_vision`. + +Both the original time-embedder DiT and the smaller AdaLN curve-table variant +are detected from their weights. + +### Download weights + +- Download minimax_h3_fl2va/minimax_h3_ref2va + - safetensors: https://huggingface.co/Comfy-Org/MiniMax-H3/tree/main/diffusion_models + - gguf: https://huggingface.co/leejet/MiniMax-H3-GGUF/tree/main +- Download qwen3vl_32b_minimax_h3 + - safetensors: https://huggingface.co/Comfy-Org/MiniMax-H3/tree/main/text_encoders + - gguf: https://huggingface.co/leejet/MiniMax-H3-GGUF/tree/main +- Download vae + - safetensors: https://huggingface.co/Comfy-Org/MiniMax-H3/tree/main/vae +- Download audio vae + - safetensors: https://huggingface.co/Comfy-Org/MiniMax-H3/tree/main/vae + +## Text-to-audio-video + +```sh +.\bin\Release\sd-cli.exe -M vid_gen --diffusion-model ..\models\diffusion_models\minimax_h3_fl2va-Q4_K_M.gguf --vae ..\models\vae\minimax_h3_video_vae_fp16.safetensors --audio-vae ..\models\vae\minimax_h3_audio_vae_fp32.safetensors --llm ..\models\text_encoders\qwen3vl_32b_minimax_h3-Q4_K_M.gguf -p "A cute American Shorthair silver tabby kitten surfs on a tropical ocean wave, riding a white surfboard with the clear text 'sd.cpp' on it. Cinematic tracking shot, realistic water, bright sunlight, smooth motion, and consistent character appearance. Add upbeat tropical surf-rock background music with cheerful drums and guitar, synchronized with the kitten’s energetic surfing." --cfg-scale 1.0 -v -W 864 -H 480 --diffusion-fa --offload-to-cpu --rng cpu --fps 24 --video-frames 56 +``` + + + +Omitting `--audio-vae` still runs the joint diffusion model but produces video without a +decoded audio track. + +## First/last-frame conditioning + +Add `--init-img` for I2VA, or both `--init-img` and `--end-img` for FL2VA: + +```sh +.\bin\Release\sd-cli.exe -M vid_gen --diffusion-model ..\models\diffusion_models\minimax_h3_fl2va-Q4_K_M.gguf --vae ..\models\vae\minimax_h3_video_vae_fp16.safetensors --audio-vae ..\models\vae\minimax_h3_audio_vae_fp32.safetensors --llm ..\models\text_encoders\qwen3vl_32b_minimax_h3-Q4_K_M.gguf -p "a lovely cat" -i ..\assets\ernie_image\turbo_example.png --cfg-scale 1.0 -v -W 864 -H 480 --diffusion-fa --offload-to-cpu --rng cpu --fps 24 --video-frames 56 +``` + + + +## Reference-to-audio-video conditioning + +Ref2VA accepts any combination of reference images, reference videos, paired +video soundtracks, and standalone audio references: + +```sh +.\bin\Release\sd-cli.exe -M vid_gen --diffusion-model ..\models\diffusion_models\minimax_h3_ref2va_pruned-Q4_K_M.gguf --vae ..\models\vae\minimax_h3_video_vae_fp16.safetensors --audio-vae ..\models\vae\minimax_h3_audio_vae_fp32.safetensors --llm ..\models\text_encoders\qwen3vl_32b_minimax_h3-Q4_K_M.gguf -p "Use the cat from as the main character. Keep the cat’s appearance, fur color, facial features, and identity consistent with the reference image. Create a 2-second cinematic video: start with an extreme close-up shot of the cat’s face, focusing on its cute expression and detailed fur texture. The camera slowly rotates around the cat’s head, creating a dynamic reveal. Then smoothly pull back and zoom out to reveal the full scene: the cat is standing confidently on a surfboard, riding ocean waves. Water splashes around the board, sea breeze gently moves the cat’s fur, and the cat maintains a cute and fearless expression while surfing. Smooth camera movement, cinematic orbit shot, seamless zoom-out transition, low-angle wide shot, realistic ocean environment, golden sunlight, dynamic waves, high-quality realistic style, natural motion, no distortion, keep the cat’s identity unchanged." -r ..\assets\ernie_image\turbo_example.png --cfg-scale 1.0 -v -W 864 -H 480 --diffusion-fa --offload-to-cpu --rng cpu --fps 24 --video-frames 56 +``` + + + +`--ref-image`, `--ref-video`, and `--ref-audio` can each be repeated. A +reference video is a directory of image frames sorted lexicographically and is +treated as 24 fps. Repeated `--ref-video-audio` WAV files are paired by index +with repeated `--ref-video` inputs. WAV PCM (8/16/24/32-bit) and 32/64-bit +floating-point samples are accepted; audio is converted to stereo 32 kHz by the +pipeline. + +Reference inputs are presented to Qwen3-VL in image, video, then audio order. +Videos are sampled at 2 fps for the Qwen presentation while their full 24 fps +latents condition the diffusion transformer. Paired video and audio references +share the same timeline. Ref2VA cannot be combined with `--init-img` or +`--end-img` in one request. + +Reference images keep their aspect ratio and are only downscaled when their +pixel area exceeds the requested generation canvas. + +The C API exposes the same inputs through `ref_images`, `ref_videos`, and +`ref_audios` in `sd_vid_gen_params_t`. Each `sd_ref_video_t` supplies its own +frame rate and optional soundtrack; non-24-fps inputs are resampled internally. + +## Shape and runtime notes + +- Width and height are aligned upward to a multiple of 32. +- Frame count is aligned upward to the `17k + 5` grid, with a minimum of 5. +- MiniMax-H3 runs at 24 fps; another requested value is overridden. +- The default video flow shift is 12. The audio stream is mapped internally to + its shift of 3, so the regular samplers can operate on the packed AV latent. diff --git a/examples/cli/main.cpp b/examples/cli/main.cpp index af62a9a93..1cc7a7af4 100644 --- a/examples/cli/main.cpp +++ b/examples/cli/main.cpp @@ -754,6 +754,18 @@ int main(int argc, const char* argv[]) { return true; }; + auto load_audio = [&](const std::string& path, SDAudioOwner& audio) -> bool { + std::vector samples; + uint32_t sample_rate = 0; + uint32_t channels = 0; + if (!load_wav_from_file(path, samples, sample_rate, channels)) { + LOG_ERROR("load WAV audio from '%s' failed", path.c_str()); + return false; + } + audio.reset(std::move(samples), sample_rate, channels); + return true; + }; + if (gen_params.init_image_path.size() > 0) { if (!load_image_and_update_size(gen_params.init_image_path, gen_params.init_image)) { return 1; @@ -777,6 +789,37 @@ int main(int argc, const char* argv[]) { } } + if (!gen_params.ref_video_paths.empty()) { + gen_params.ref_videos.clear(); + gen_params.ref_videos.reserve(gen_params.ref_video_paths.size()); + for (const auto& path : gen_params.ref_video_paths) { + std::vector frames; + if (!load_images_from_dir(path, frames, 0, 0, 0, cli_params.verbose) || frames.empty()) { + LOG_ERROR("load reference video frames from '%s' failed", path.c_str()); + return 1; + } + gen_params.ref_videos.push_back(std::move(frames)); + } + + gen_params.ref_video_audios.clear(); + gen_params.ref_video_audios.resize(gen_params.ref_videos.size()); + for (size_t i = 0; i < gen_params.ref_video_audio_paths.size(); ++i) { + if (!load_audio(gen_params.ref_video_audio_paths[i], gen_params.ref_video_audios[i])) { + return 1; + } + } + } + + if (!gen_params.ref_audio_paths.empty()) { + gen_params.ref_audios.clear(); + gen_params.ref_audios.resize(gen_params.ref_audio_paths.size()); + for (size_t i = 0; i < gen_params.ref_audio_paths.size(); ++i) { + if (!load_audio(gen_params.ref_audio_paths[i], gen_params.ref_audios[i])) { + return 1; + } + } + } + if (gen_params.mask_image_path.size() > 0) { if (!load_sd_image_from_file(gen_params.mask_image.put(), gen_params.mask_image_path.c_str(), diff --git a/examples/common/common.cpp b/examples/common/common.cpp index ceb781430..d6effe5e5 100644 --- a/examples/common/common.cpp +++ b/examples/common/common.cpp @@ -1404,6 +1404,30 @@ ArgOptions SDGenerationParams::get_options() { return 1; }; + auto on_ref_video_arg = [&](int argc, const char** argv, int index) { + if (++index >= argc) { + return -1; + } + ref_video_paths.push_back(argv[index]); + return 1; + }; + + auto on_ref_video_audio_arg = [&](int argc, const char** argv, int index) { + if (++index >= argc) { + return -1; + } + ref_video_audio_paths.push_back(argv[index]); + return 1; + }; + + auto on_ref_audio_arg = [&](int argc, const char** argv, int index) { + if (++index >= argc) { + return -1; + } + ref_audio_paths.push_back(argv[index]); + return 1; + }; + auto on_cache_mode_arg = [&](int argc, const char** argv, int index) { if (++index >= argc) { return -1; @@ -1568,8 +1592,20 @@ ArgOptions SDGenerationParams::get_options() { on_high_noise_skip_layers_arg}, {"-r", "--ref-image", - "reference image for Flux Kontext models (can be used multiple times)", + "reference image for Flux Kontext or MiniMax-H3 Ref2VA (can be used multiple times)", on_ref_image_arg}, + {"", + "--ref-video", + "MiniMax-H3 Ref2VA reference video frame directory at 24 fps (can be used multiple times)", + on_ref_video_arg}, + {"", + "--ref-video-audio", + "WAV soundtrack paired by index with --ref-video (can be used multiple times)", + on_ref_video_audio_arg}, + {"", + "--ref-audio", + "standalone WAV reference for MiniMax-H3 Ref2VA (can be used multiple times)", + on_ref_audio_arg}, {"", "--cache-mode", "caching method: 'easycache' (DiT), 'ucache' (UNET), 'dbcache'/'taylorseer'/'cache-dit' (DiT block-level), 'spectrum' (UNET/DiT Chebyshev+Taylor forecasting)", @@ -2366,6 +2402,16 @@ bool SDGenerationParams::validate(SDMode mode) { return false; } + if (ref_video_audio_paths.size() > ref_video_paths.size()) { + LOG_ERROR("error: each --ref-video-audio needs a corresponding --ref-video"); + return false; + } + + if (mode != VID_GEN && (!ref_video_paths.empty() || !ref_video_audio_paths.empty() || !ref_audio_paths.empty())) { + LOG_ERROR("error: reference video and audio inputs require vid_gen mode"); + return false; + } + if (sample_params.shifted_timestep < 0 || sample_params.shifted_timestep > 1000) { LOG_ERROR("error: shifted_timestep must be in range [0, 1000]"); return false; @@ -2560,6 +2606,35 @@ sd_vid_gen_params_t SDGenerationParams::to_sd_vid_gen_params_t() { control_frame_views.push_back(frame.get()); } + ref_image_views.clear(); + ref_image_views.reserve(ref_images.size()); + for (auto& image : ref_images) { + ref_image_views.push_back(image.get()); + } + + ref_video_frame_views.clear(); + ref_video_frame_views.resize(ref_videos.size()); + ref_video_views.clear(); + ref_video_views.reserve(ref_videos.size()); + for (size_t i = 0; i < ref_videos.size(); ++i) { + auto& frame_views = ref_video_frame_views[i]; + frame_views.reserve(ref_videos[i].size()); + for (auto& frame : ref_videos[i]) { + frame_views.push_back(frame.get()); + } + sd_audio_t audio = i < ref_video_audios.size() ? ref_video_audios[i].get() : sd_audio_t{}; + ref_video_views.push_back({frame_views.empty() ? nullptr : frame_views.data(), + static_cast(frame_views.size()), + 24, + audio}); + } + + ref_audio_views.clear(); + ref_audio_views.reserve(ref_audios.size()); + for (auto& audio : ref_audios) { + ref_audio_views.push_back(audio.get()); + } + sample_params.guidance.slg.layers = skip_layers.empty() ? nullptr : skip_layers.data(); sample_params.guidance.slg.layer_count = skip_layers.size(); high_noise_sample_params.guidance.slg.layers = high_noise_skip_layers.empty() ? nullptr : high_noise_skip_layers.data(); @@ -2578,6 +2653,12 @@ sd_vid_gen_params_t SDGenerationParams::to_sd_vid_gen_params_t() { params.clip_skip = clip_skip; params.init_image = init_image.get(); params.end_image = end_image.get(); + params.ref_images = ref_image_views.empty() ? nullptr : ref_image_views.data(); + params.ref_images_count = static_cast(ref_image_views.size()); + params.ref_videos = ref_video_views.empty() ? nullptr : ref_video_views.data(); + params.ref_videos_count = static_cast(ref_video_views.size()); + params.ref_audios = ref_audio_views.empty() ? nullptr : ref_audio_views.data(); + params.ref_audios_count = static_cast(ref_audio_views.size()); params.control_frames = control_frame_views.empty() ? nullptr : control_frame_views.data(); params.control_frames_size = static_cast(control_frame_views.size()); params.width = get_resolved_width(); @@ -2657,6 +2738,9 @@ std::string SDGenerationParams::to_string() const { << " mask_image_path: \"" << mask_image_path << "\",\n" << " control_image_path: \"" << control_image_path << "\",\n" << " ref_image_paths: " << vec_str_to_string(ref_image_paths) << ",\n" + << " ref_video_paths: " << vec_str_to_string(ref_video_paths) << ",\n" + << " ref_video_audio_paths: " << vec_str_to_string(ref_video_audio_paths) << ",\n" + << " ref_audio_paths: " << vec_str_to_string(ref_audio_paths) << ",\n" << " control_video_path: \"" << control_video_path << "\",\n" << " auto_resize_ref_image: " << (auto_resize_ref_image ? "true" : "false") << ",\n" << " increase_ref_index: " << (increase_ref_index ? "true" : "false") << ",\n" diff --git a/examples/common/common.h b/examples/common/common.h index ea90c8c1b..34b4a013b 100644 --- a/examples/common/common.h +++ b/examples/common/common.h @@ -212,6 +212,9 @@ struct SDGenerationParams { std::string control_image_path; std::string ip_adapter_image_path; std::vector ref_image_paths; + std::vector ref_video_paths; + std::vector ref_video_audio_paths; + std::vector ref_audio_paths; std::string control_video_path; sd_sample_params_t sample_params; @@ -275,6 +278,9 @@ struct SDGenerationParams { SDImageOwner init_image; SDImageOwner end_image; std::vector ref_images; + std::vector> ref_videos; + std::vector ref_video_audios; + std::vector ref_audios; SDImageOwner mask_image; SDImageOwner control_image; SDImageOwner ip_adapter_image; @@ -283,6 +289,9 @@ struct SDGenerationParams { // Backing storage for sd_img_gen_params_t view fields. std::vector ref_image_views; + std::vector> ref_video_frame_views; + std::vector ref_video_views; + std::vector ref_audio_views; std::vector pm_id_image_views; std::vector control_frame_views; diff --git a/examples/common/media_io.cpp b/examples/common/media_io.cpp index 506c67f4d..812a8c80e 100644 --- a/examples/common/media_io.cpp +++ b/examples/common/media_io.cpp @@ -1374,3 +1374,132 @@ bool write_wav_to_file(const std::string& path, file.write(reinterpret_cast(pcm.data()), static_cast(pcm.size() * sizeof(int16_t))); return file.good(); } + +static uint16_t read_le16(const uint8_t* data) { + return static_cast(data[0]) | + (static_cast(data[1]) << 8); +} + +static uint32_t read_le32(const uint8_t* data) { + return static_cast(data[0]) | + (static_cast(data[1]) << 8) | + (static_cast(data[2]) << 16) | + (static_cast(data[3]) << 24); +} + +bool load_wav_from_file(const std::string& path, + std::vector& interleaved_samples, + uint32_t& sample_rate, + uint32_t& channels) { + interleaved_samples.clear(); + sample_rate = 0; + channels = 0; + + std::ifstream file(path, std::ios::binary); + uint8_t riff_header[12]; + if (!file.read(reinterpret_cast(riff_header), sizeof(riff_header)) || + std::memcmp(riff_header, "RIFF", 4) != 0 || + std::memcmp(riff_header + 8, "WAVE", 4) != 0) { + return false; + } + + uint16_t audio_format = 0; + uint16_t bits_per_sample = 0; + uint16_t block_align = 0; + std::streampos data_pos = std::streampos(-1); + uint32_t data_size = 0; + + while (file.good()) { + uint8_t chunk_header[8]; + if (!file.read(reinterpret_cast(chunk_header), sizeof(chunk_header))) { + break; + } + uint32_t chunk_size = read_le32(chunk_header + 4); + std::streampos chunk_data_pos = file.tellg(); + + if (std::memcmp(chunk_header, "fmt ", 4) == 0) { + if (chunk_size < 16) { + return false; + } + std::vector fmt(chunk_size); + if (!file.read(reinterpret_cast(fmt.data()), chunk_size)) { + return false; + } + audio_format = read_le16(fmt.data()); + channels = read_le16(fmt.data() + 2); + sample_rate = read_le32(fmt.data() + 4); + block_align = read_le16(fmt.data() + 12); + bits_per_sample = read_le16(fmt.data() + 14); + if (audio_format == 0xfffe && chunk_size >= 40) { + audio_format = read_le16(fmt.data() + 24); + } + } else if (std::memcmp(chunk_header, "data", 4) == 0) { + data_pos = chunk_data_pos; + data_size = chunk_size; + file.seekg(chunk_size, std::ios::cur); + } else { + file.seekg(chunk_size, std::ios::cur); + } + + if (!file.good()) { + break; + } + if ((chunk_size & 1) != 0) { + file.seekg(1, std::ios::cur); + } + } + + const uint32_t bytes_per_sample = (bits_per_sample + 7) / 8; + if (data_pos == std::streampos(-1) || data_size == 0 || channels == 0 || sample_rate == 0 || + block_align == 0 || bytes_per_sample == 0 || block_align < channels * bytes_per_sample || + (audio_format != 1 && audio_format != 3)) { + return false; + } + + const uint64_t frame_count = data_size / block_align; + if (frame_count == 0 || frame_count > SIZE_MAX / channels) { + return false; + } + std::vector pcm(data_size); + file.clear(); + file.seekg(data_pos); + if (!file.read(reinterpret_cast(pcm.data()), data_size)) { + return false; + } + + interleaved_samples.resize(static_cast(frame_count * channels)); + for (uint64_t frame = 0; frame < frame_count; ++frame) { + const uint8_t* frame_data = pcm.data() + frame * block_align; + for (uint32_t channel = 0; channel < channels; ++channel) { + const uint8_t* sample_data = frame_data + channel * bytes_per_sample; + float sample = 0.0f; + if (audio_format == 3 && bits_per_sample == 32) { + std::memcpy(&sample, sample_data, sizeof(sample)); + } else if (audio_format == 3 && bits_per_sample == 64) { + double value; + std::memcpy(&value, sample_data, sizeof(value)); + sample = static_cast(value); + } else if (audio_format == 1 && bits_per_sample == 8) { + sample = (static_cast(sample_data[0]) - 128) / 128.0f; + } else if (audio_format == 1 && bits_per_sample == 16) { + sample = static_cast(read_le16(sample_data)) / 32768.0f; + } else if (audio_format == 1 && bits_per_sample == 24) { + int32_t value = static_cast(sample_data[0]) | + (static_cast(sample_data[1]) << 8) | + (static_cast(sample_data[2]) << 16); + if ((value & 0x800000) != 0) { + value |= ~0xffffff; + } + sample = value / 8388608.0f; + } else if (audio_format == 1 && bits_per_sample == 32) { + int32_t value = static_cast(read_le32(sample_data)); + sample = value / 2147483648.0f; + } else { + interleaved_samples.clear(); + return false; + } + interleaved_samples[static_cast(frame * channels + channel)] = sample; + } + } + return true; +} diff --git a/examples/common/media_io.h b/examples/common/media_io.h index 0f7679d7f..52b9221a2 100644 --- a/examples/common/media_io.h +++ b/examples/common/media_io.h @@ -110,4 +110,9 @@ bool write_wav_to_file(const std::string& path, uint32_t channels, uint32_t sample_rate); +bool load_wav_from_file(const std::string& path, + std::vector& interleaved_samples, + uint32_t& sample_rate, + uint32_t& channels); + #endif // __MEDIA_IO_H__ diff --git a/examples/common/resource_owners.hpp b/examples/common/resource_owners.hpp index d7525a5fa..73f8bdd81 100644 --- a/examples/common/resource_owners.hpp +++ b/examples/common/resource_owners.hpp @@ -141,6 +141,37 @@ class SDImageOwner { } }; +class SDAudioOwner { +private: + uint32_t sample_rate_ = 0; + uint32_t channels_ = 0; + std::vector samples_; + +public: + SDAudioOwner() = default; + + void reset(std::vector samples = {}, uint32_t sample_rate = 0, uint32_t channels = 0) { + samples_ = std::move(samples); + sample_rate_ = sample_rate; + channels_ = channels; + } + + bool empty() const { + return samples_.empty(); + } + + sd_audio_t get() { + return {sample_rate_, + channels_, + channels_ == 0 ? 0 : static_cast(samples_.size() / channels_), + samples_.empty() ? nullptr : samples_.data()}; + } + + const std::vector& samples() const { + return samples_; + } +}; + class SDImageVec { private: std::vector images_; diff --git a/include/stable-diffusion.h b/include/stable-diffusion.h index bd568c686..22fbc05e4 100644 --- a/include/stable-diffusion.h +++ b/include/stable-diffusion.h @@ -246,6 +246,13 @@ typedef struct { uint8_t* data; } sd_image_t; +typedef struct { + sd_image_t* frames; + int frame_count; + int fps; + sd_audio_t audio; +} sd_ref_video_t; + typedef struct { int* layers; size_t layer_count; @@ -396,6 +403,12 @@ typedef struct { int clip_skip; sd_image_t init_image; sd_image_t end_image; + sd_image_t* ref_images; + int ref_images_count; + sd_ref_video_t* ref_videos; + int ref_videos_count; + sd_audio_t* ref_audios; + int ref_audios_count; sd_image_t* control_frames; int control_frames_size; int width; diff --git a/scripts/merge_safetensors.py b/scripts/merge_safetensors.py new file mode 100644 index 000000000..aebae3065 --- /dev/null +++ b/scripts/merge_safetensors.py @@ -0,0 +1,335 @@ +#!/usr/bin/env python3 +"""Merge selected tensors from multiple safetensors files without loading weights. + +Edit ``OUTPUT_PATH`` and ``SOURCE_RULES`` below, then run: + + python scripts/merge_safetensors.py + +Each source rule uses regular expressions against complete tensor names. +``include`` is required and matches when any expression succeeds. ``exclude`` +wins over ``include``. Expressions are evaluated with ``re.search``. +""" + +import json +import os +import re +import struct +from dataclasses import dataclass +from pathlib import Path +from typing import BinaryIO + + +# ----------------------------------------------------------------------------- +# Configuration +# ----------------------------------------------------------------------------- + +OUTPUT_PATH = Path(r"..\models\diffusion_models\minimax_h3_ref2va_pruned_bf16.safetensors") + +SOURCE_RULES = [ + { + "path": Path(r"..\models\diffusion_models\minimax_h3_ref2va_bf16.safetensors"), + "include": [r".*"], + "exclude": [r".*adaln_proj\.linear.*", r"time_embedder.*"], + }, + { + "path": Path(r"..\models\diffusion_models\minimax_h3_ref2va_pruned_int8_convrot.safetensors"), + "include": [r"^.*adaln_proj\.linear.*", "adaln_t_table"], + "exclude": [], + }, +] + +# Safetensors metadata is optional. Set this to a dict[str, str] if needed. +OUTPUT_METADATA = None + +# Refuse to replace an existing output unless explicitly enabled. +OVERWRITE_OUTPUT = False + +# Only tensor headers and this fixed-size buffer are held in memory. +COPY_BUFFER_SIZE = 8 * 1024 * 1024 +PROGRESS_INTERVAL = 1024 * 1024 * 1024 +MAX_HEADER_SIZE = 256 * 1024 * 1024 + + +@dataclass(frozen=True) +class TensorEntry: + name: str + source_path: Path + source_data_offset: int + source_start: int + source_end: int + dtype: str + shape: list[int] + + @property + def size(self) -> int: + return self.source_end - self.source_start + + +def format_bytes(size: int) -> str: + value = float(size) + for unit in ("B", "KiB", "MiB", "GiB", "TiB"): + if value < 1024.0 or unit == "TiB": + return f"{value:.2f} {unit}" + value /= 1024.0 + raise AssertionError("unreachable") + + +def read_exact(file: BinaryIO, size: int, description: str) -> bytes: + data = file.read(size) + if len(data) != size: + raise ValueError(f"truncated {description}: expected {size} bytes, got {len(data)}") + return data + + +def read_safetensors_header(path: Path) -> tuple[dict, int, int]: + file_size = path.stat().st_size + with path.open("rb") as file: + header_size = struct.unpack(" MAX_HEADER_SIZE: + raise ValueError( + f"invalid header size in {path}: {header_size} " + f"(limit: {MAX_HEADER_SIZE})" + ) + header_bytes = read_exact(file, header_size, f"header in {path}") + + try: + header = json.loads(header_bytes) + except (UnicodeDecodeError, json.JSONDecodeError) as error: + raise ValueError(f"invalid safetensors JSON header in {path}: {error}") from error + if not isinstance(header, dict): + raise ValueError(f"safetensors header in {path} is not an object") + + data_offset = 8 + header_size + if data_offset > file_size: + raise ValueError(f"safetensors data offset is past end of file: {path}") + return header, data_offset, file_size + + +def parse_tensor_entry( + name: str, + info: object, + source_path: Path, + source_data_offset: int, + source_file_size: int, +) -> TensorEntry: + if not isinstance(info, dict): + raise ValueError(f"{source_path}: tensor {name!r} has an invalid header entry") + + dtype = info.get("dtype") + shape = info.get("shape") + offsets = info.get("data_offsets") + if not isinstance(dtype, str): + raise ValueError(f"{source_path}: tensor {name!r} has an invalid dtype") + if not isinstance(shape, list) or not all( + isinstance(dimension, int) and dimension >= 0 for dimension in shape + ): + raise ValueError(f"{source_path}: tensor {name!r} has an invalid shape") + if ( + not isinstance(offsets, list) + or len(offsets) != 2 + or not all(isinstance(offset, int) for offset in offsets) + ): + raise ValueError(f"{source_path}: tensor {name!r} has invalid data offsets") + + start, end = offsets + if start < 0 or end < start or source_data_offset + end > source_file_size: + raise ValueError( + f"{source_path}: tensor {name!r} byte range [{start}, {end}) " + "is outside the file" + ) + + return TensorEntry( + name=name, + source_path=source_path, + source_data_offset=source_data_offset, + source_start=start, + source_end=end, + dtype=dtype, + shape=list(shape), + ) + + +def compile_patterns(rule_index: int, field: str, values: object) -> list[re.Pattern[str]]: + if not isinstance(values, list) or not all(isinstance(value, str) for value in values): + raise TypeError(f"SOURCE_RULES[{rule_index}][{field!r}] must be a list of strings") + try: + return [re.compile(value) for value in values] + except re.error as error: + raise ValueError( + f"invalid regex in SOURCE_RULES[{rule_index}][{field!r}]: {error}" + ) from error + + +def collect_entries() -> list[TensorEntry]: + if not SOURCE_RULES: + raise ValueError("SOURCE_RULES must contain at least one source") + + entries: list[TensorEntry] = [] + selected_by_name: dict[str, TensorEntry] = {} + header_cache: dict[Path, tuple[dict, int, int]] = {} + + for rule_index, rule in enumerate(SOURCE_RULES): + if not isinstance(rule, dict) or "path" not in rule or "include" not in rule: + raise TypeError( + f"SOURCE_RULES[{rule_index}] must contain 'path' and 'include'" + ) + + source_path = Path(rule["path"]) + if not source_path.is_file(): + raise FileNotFoundError(f"source file does not exist: {source_path}") + source_path = source_path.resolve() + + include = compile_patterns(rule_index, "include", rule["include"]) + exclude = compile_patterns(rule_index, "exclude", rule.get("exclude", [])) + if not include: + raise ValueError(f"SOURCE_RULES[{rule_index}]['include'] must not be empty") + + if source_path not in header_cache: + header_cache[source_path] = read_safetensors_header(source_path) + header, data_offset, file_size = header_cache[source_path] + + matched = 0 + for name, info in header.items(): + if name == "__metadata__": + continue + if not any(pattern.search(name) for pattern in include): + continue + if any(pattern.search(name) for pattern in exclude): + continue + + entry = parse_tensor_entry(name, info, source_path, data_offset, file_size) + previous = selected_by_name.get(name) + if previous is not None: + raise ValueError( + f"tensor {name!r} was selected more than once:\n" + f" first: {previous.source_path}\n" + f" second: {source_path}" + ) + selected_by_name[name] = entry + print(f"entry {entry}") + entries.append(entry) + matched += 1 + + print(f"Rule {rule_index}: selected {matched} tensors from {source_path}") + if matched == 0: + raise ValueError( + f"SOURCE_RULES[{rule_index}] did not select any tensors; check its regexes" + ) + + if not entries: + raise ValueError("no tensors were selected") + return entries + + +def build_output_header(entries: list[TensorEntry]) -> tuple[bytes, int]: + header: dict[str, object] = {} + if OUTPUT_METADATA is not None: + if not isinstance(OUTPUT_METADATA, dict) or not all( + isinstance(key, str) and isinstance(value, str) + for key, value in OUTPUT_METADATA.items() + ): + raise TypeError("OUTPUT_METADATA must be None or a dict[str, str]") + header["__metadata__"] = OUTPUT_METADATA + + output_offset = 0 + for entry in entries: + header[entry.name] = { + "dtype": entry.dtype, + "shape": entry.shape, + "data_offsets": [output_offset, output_offset + entry.size], + } + output_offset += entry.size + + header_bytes = json.dumps(header, separators=(",", ":"), ensure_ascii=False).encode( + "utf-8" + ) + header_bytes += b" " * (-len(header_bytes) % 8) + return header_bytes, output_offset + + +def copy_tensor(source: BinaryIO, output: BinaryIO, entry: TensorEntry) -> None: + source.seek(entry.source_data_offset + entry.source_start) + remaining = entry.size + while remaining: + chunk = source.read(min(COPY_BUFFER_SIZE, remaining)) + if not chunk: + raise OSError( + f"unexpected end of file while copying {entry.name!r} " + f"from {entry.source_path}" + ) + output.write(chunk) + remaining -= len(chunk) + + +def write_output(entries: list[TensorEntry]) -> None: + if COPY_BUFFER_SIZE <= 0: + raise ValueError("COPY_BUFFER_SIZE must be positive") + + output_path = OUTPUT_PATH.resolve() + source_paths = {entry.source_path.resolve() for entry in entries} + if output_path in source_paths: + raise ValueError("OUTPUT_PATH must not be one of the source files") + if output_path.exists() and not OVERWRITE_OUTPUT: + raise FileExistsError( + f"output already exists: {output_path}; set OVERWRITE_OUTPUT = True to replace it" + ) + + output_path.parent.mkdir(parents=True, exist_ok=True) + partial_path = output_path.with_name(output_path.name + ".partial") + if partial_path.exists(): + raise FileExistsError( + f"partial output already exists: {partial_path}; remove it before retrying" + ) + + header_bytes, tensor_bytes = build_output_header(entries) + print( + f"Writing {len(entries)} tensors ({format_bytes(tensor_bytes)}) to {output_path}" + ) + + current_source_path: Path | None = None + current_source: BinaryIO | None = None + copied = 0 + next_progress = PROGRESS_INTERVAL + try: + with partial_path.open("xb") as output: + output.write(struct.pack(" 0 and copied >= next_progress: + print( + f" copied {format_bytes(copied)} / " + f"{format_bytes(tensor_bytes)}" + ) + while next_progress <= copied: + next_progress += PROGRESS_INTERVAL + finally: + if current_source is not None: + current_source.close() + + if copied != tensor_bytes: + raise OSError(f"copied {copied} tensor bytes, expected {tensor_bytes}") + os.replace(partial_path, output_path) + except BaseException: + partial_path.unlink(missing_ok=True) + raise + + print(f"Done: {output_path} ({format_bytes(output_path.stat().st_size)})") + + +def main() -> None: + entries = collect_entries() + write_output(entries) + + +if __name__ == "__main__": + main() diff --git a/src/conditioning/conditioner.hpp b/src/conditioning/conditioner.hpp index 0b1f0d351..3d7ff0397 100644 --- a/src/conditioning/conditioner.hpp +++ b/src/conditioning/conditioner.hpp @@ -2,8 +2,10 @@ #define __SD_CONDITIONING_CONDITIONER_HPP__ #include +#include #include #include +#include #include "core/tensor_ggml.hpp" #include "core/util.h" @@ -25,6 +27,8 @@ struct SDCondition { sd::Tensor c_vinput_mask; std::vector>> c_image_embeds; std::vector> c_ref_images; + std::vector> c_ref_audios; + std::vector c_reference_blocks; std::vector> extra_c_crossattns; @@ -55,6 +59,12 @@ struct SDCondition { } } + for (const auto& tensor : c_ref_audios) { + if (!tensor.empty()) { + return false; + } + } + for (const auto& tensor : extra_c_crossattns) { if (!tensor.empty()) { return false; @@ -65,6 +75,18 @@ struct SDCondition { } }; +enum class MiniMaxH3PresentationKind { + IMAGE, + VIDEO, + AUDIO, +}; + +struct MiniMaxH3PresentationItem { + MiniMaxH3PresentationKind kind = MiniMaxH3PresentationKind::IMAGE; + std::vector> frames; + std::vector timestamps; +}; + static inline sd::Tensor apply_token_weights(sd::Tensor hidden_states, const std::vector& weights) { if (hidden_states.empty()) { @@ -102,11 +124,12 @@ static inline sd::Tensor apply_token_weights(sd::Tensor hidden_sta struct ConditionerParams { std::string text; - int clip_skip = -1; - int width = -1; - int height = -1; - bool zero_out_masked = false; - const std::vector>* ref_images = nullptr; // for qwen image edit + int clip_skip = -1; + int width = -1; + int height = -1; + bool zero_out_masked = false; + const std::vector>* ref_images = nullptr; // for qwen image edit + const std::vector* minimax_h3_references = nullptr; RefImageParams ref_image_params; }; @@ -1810,6 +1833,7 @@ struct LLMEmbedder : public Conditioner { sd_version_is_boogu_image(version) || sd_version_is_sefi_image(version) || sd_version_is_krea2(version) || + sd_version_is_minimax_h3(version) || sd_version_is_mage_flow(version)) { arch = LLM::LLMArch::QWEN3_VL; } else if (sd_version_is_z_image(version) || version == VERSION_OVIS_IMAGE || version == VERSION_FLUX2_KLEIN) { @@ -1992,8 +2016,10 @@ struct LLMEmbedder : public Conditioner { const std::vector>>& image_embeds, const std::set& out_layers, int prompt_template_encode_start_idx, - bool spell_quotes = false, - int max_length = 100000000) { + bool spell_quotes = false, + int max_length = 100000000, + const LLM::DeepStackImageEmbeds& deepstack_image_embeds = {}, + const std::vector& image_grids = {}) { auto tokens_weights_mask = tokenize(prompt, prompt_attn_range, min_length, max_length, spell_quotes); auto& tokens = std::get<0>(tokens_weights_mask); auto& weights = std::get<1>(tokens_weights_mask); @@ -2026,7 +2052,9 @@ struct LLMEmbedder : public Conditioner { false, false, true, - true); + true, + deepstack_image_embeds, + image_grids); GGML_ASSERT(!hidden_states.empty()); hidden_states = apply_token_weights(std::move(hidden_states), weights); GGML_ASSERT(hidden_states.shape()[1] > prompt_template_encode_start_idx); @@ -2108,6 +2136,8 @@ struct LLMEmbedder : public Conditioner { std::vector extra_prompts; std::vector> extra_prompts_attn_range; std::vector>> image_embeds; + LLM::DeepStackImageEmbeds deepstack_image_embeds; + std::vector image_grids; int prompt_template_encode_start_idx = 34; int min_length = 0; // pad tokens int max_length = 100000000; @@ -2118,7 +2148,131 @@ struct LLMEmbedder : public Conditioner { int64_t t0 = ggml_time_ms(); RefImageResizeMode resize_mode = conditioner_params.ref_image_params.vlm_resize_mode; - if (sd_version_is_hunyuan_video(version)) { + if (sd_version_is_minimax_h3(version)) { + prompt_template_encode_start_idx = 0; + out_layers = {50}; + prompt_attn_range = {0, 0}; + + if (llm->enable_vision) { + const std::string placeholder = "<|image_pad|>"; + const int patch_size = llm->config.vision.patch_size; + const int factor = patch_size * llm->config.vision.spatial_merge_size; + + auto resize_for_vision = [&](const sd::Tensor& image) { + int height = static_cast(image.shape()[1]); + int width = static_cast(image.shape()[0]); + int h_bar = std::max(factor, static_cast(std::round(static_cast(height) / factor)) * factor); + int w_bar = std::max(factor, static_cast(std::round(static_cast(width) / factor)) * factor); + resize_image_dims(height, + width, + h_bar, + w_bar, + factor, + 3136, + 12845056, + RefImageResizeMode::AREA); + auto resized = sd::ops::interpolate( + image, + std::vector{w_bar, h_bar, image.shape()[2], image.shape()[3]}); + for (int64_t i = 0; i < resized.numel(); ++i) { + resized[i] = std::clamp(resized[i], 0.f, 1.f) * 2.f - 1.f; + } + return resized; + }; + + auto add_vision_outputs = [&](std::vector> image_outputs, + int grid_h, + int grid_w) { + GGML_ASSERT(image_outputs.size() == 4); + auto image_embed = std::move(image_outputs[0]); + prompt += "<|vision_start|>"; + int image_embed_idx = static_cast(tokenizer->encode(prompt, nullptr).size()); + image_embeds.emplace_back(image_embed_idx, image_embed); + if (deepstack_image_embeds.empty()) { + deepstack_image_embeds.resize(image_outputs.size() - 1); + } + for (size_t layer = 0; layer < deepstack_image_embeds.size(); ++layer) { + deepstack_image_embeds[layer].emplace_back(image_embed_idx, std::move(image_outputs[layer + 1])); + } + image_grids.push_back({image_embed_idx, + static_cast(image_embed.shape()[1]), + grid_h, + grid_w}); + for (int64_t i = 0; i < image_embed.shape()[1]; ++i) { + prompt += placeholder; + } + prompt += "<|vision_end|>"; + }; + + const auto* references = conditioner_params.minimax_h3_references; + if (references != nullptr && !references->empty()) { + int picture_index = 0; + int video_index = 0; + int audio_index = 0; + for (const auto& item : *references) { + if (item.kind == MiniMaxH3PresentationKind::AUDIO) { + prompt += "