Video Generation

Two families: MiniMax-H3 generates video together with a native stereo soundtrack, and Wan 2.1 / 2.2 generates video alone. This page covers the downloads, the run recipes, and the settings that decide the wall clock.

πŸ’‘

These commands follow the same conventions as the text-model recipes β€” the Hugging Face CLI, a source build from the repository root, and --backend swapped to match your hardware. See Download & run, per family.

MiniMax-H3 (prompt β†’ video with audio)

H3 denoises video and 32 kHz stereo audio in one packed latent, so the soundtrack is part of the model output rather than something added afterwards. It is CFG-distilled, so --cfg 1.0 is required and 4–8 steps is the operating point against a 20-step default. Measured on an M5 Pro with ggml_metal at 22 frames, 8 steps and an identical seed, it runs 2.4Γ— faster than stable-diffusion.cpp at 256Γ—256 (49.3 s β†’ 20.9 s) and 1.7Γ— at 640Γ—384 (108.5 s β†’ 63.1 s). That is Apple silicon; on a memory-starved 16 GB RTX 3080 Laptop the same comparison lands the other way round, for reasons worth knowing before you run it β€” see Speed, on two machines. Four networks are needed, from two repositories, plus the tokenizer from a third:

# denoiser + text encoder
hf download unsloth/MiniMax-H3-GGUF minimax_h3_fl2va_pruned-Q4_K.gguf --local-dir models
hf download unsloth/MiniMax-H3-GGUF qwen3vl_32b_minimax_h3-Q4_K_M.gguf --local-dir models
# the two VAEs
hf download Comfy-Org/MiniMax-H3 vae/minimax_h3_video_vae_fp16.safetensors --local-dir models
hf download Comfy-Org/MiniMax-H3 vae/minimax_h3_audio_vae_fp32.safetensors --local-dir models
# the text encoder ships NO tokenizer: three files from the upstream repo, beside the encoder
curl -L -o models/vocab.json https://huggingface.co/MiniMaxAI/MiniMax-H3/resolve/42ed227ee7df40d41602854ae760620d6eb651fe/processor/vocab.json
curl -L -o models/merges.txt https://huggingface.co/MiniMaxAI/MiniMax-H3/resolve/42ed227ee7df40d41602854ae760620d6eb651fe/processor/merges.txt
curl -L -o models/tokenizer_config.json https://huggingface.co/MiniMaxAI/MiniMax-H3/resolve/42ed227ee7df40d41602854ae760620d6eb651fe/processor/tokenizer_config.json

# text β†’ video (+ audio). Writes fox.mp4 and fox.wav
dotnet TensorSharp.Cli/bin/TensorSharp.Cli.dll --model models/minimax_h3_fl2va_pruned-Q4_K.gguf \
    --backend ggml_metal --prompt "a red fox trotting through falling snow, cinematic" \
    --width 640 --height 384 --video-frames 22 --diffusion-steps 8 --cfg 1.0 --output fox.mp4

The tokenizer files have to sit beside the encoder, or wherever TS_VIDEO_TOKENIZER points: hf download MiniMaxAI/MiniMax-H3 processor/vocab.json … --local-dir models would keep the processor/ folder, which the loader never looks in. tokenizer_config.json is the easy one to miss and the only one that defines <|vision_start|>, <|image_pad|> and <|vision_end|>. Text-to-video runs without it, and so do references that are all sound recordings; a keyframe, or a photo or clip reference, is refused before the vision tower runs. It used to go ahead with the picture placed over the wrong prompt positions, so the clip quietly ignored it.

Or name all four networks in a config file and let the first run fetch them β€” config/minimax-h3-fl2va.json for keyframes, config/minimax-h3-ref2va.json for references. Only the model entry differs between the two, so the text encoder and both VAEs download once and are shared (~35.5 GB in total). The three tokenizer files above are the one thing auto-download cannot fetch, because they are not a flag:

dotnet TensorSharp.Cli/bin/TensorSharp.Cli.dll --config config/minimax-h3-fl2va.json \
    --prompt "a red fox trotting through falling snow, cinematic" --output fox.mp4
dotnet TensorSharp.Server.Host/bin/TensorSharp.Server.Host.dll --config config/minimax-h3-ref2va.json

What an image means

An image can mean two completely different things to H3, and they use different checkpoints. The mode is inferred from what you pass; --video-mode states it explicitly.

What you want--video-modeCheckpoint
β€œanimate this photo” β€” it becomes the first framei2vminimax_h3_fl2va_pruned-*
β€œgo from photo A to photo B”fl2vminimax_h3_fl2va_pruned-*
β€œuse this person, brand-new scene”refminimax_h3_ref2va_pruned-*
β€œreference this product, new angle and background”refminimax_h3_ref2va_pruned-*
text onlyt2veither

The two denoisers are separate files rather than settings, and each refuses the other's inputs by name: ask for ref on FL2VA and the error tells you to load minimax_h3_ref2va_pruned-*.gguf; pass --image/--end-image as keyframes on Ref2VA and it tells you to load the FL2VA file, or to re-pass the picture as --ref-image. Keyframes and named references in one request are refused outright rather than one being quietly dropped β€” a clip that came back looking like the request was honoured is the worse failure. Which partition is active is read off the file name: ref2va anywhere in it, case-insensitive, selects reference conditioning, so keep it there if you rename or requantize.

# animate a photo: it becomes the first frame
dotnet TensorSharp.Cli/bin/TensorSharp.Cli.dll --model models/minimax_h3_fl2va_pruned-Q4_K.gguf \
    --backend ggml_metal --image portrait.jpg \
    --prompt "the person turns toward the camera and smiles, subtle handheld motion" \
    --width 640 --height 384 --video-frames 22 --diffusion-steps 8 --cfg 1.0 --output animated.mp4

# first and last frame: both ends pinned, the model fills in the motion
dotnet TensorSharp.Cli/bin/TensorSharp.Cli.dll --model models/minimax_h3_fl2va_pruned-Q4_K.gguf \
    --backend ggml_metal --image start.png --end-image end.png \
    --prompt "a slow cinematic push-in" \
    --width 640 --height 384 --video-frames 22 --diffusion-steps 8 --cfg 1.0 --output morph.mp4

# reference: keep the subject, change camera, background and composition
dotnet TensorSharp.Cli/bin/TensorSharp.Cli.dll --model models/minimax_h3_ref2va_pruned-Q4_K.gguf \
    --backend ggml_metal --ref-image person.jpg --ref-image bottle.png \
    --prompt "she holds the bottle up to the light on a rooftop at golden hour, slow orbit" \
    --width 640 --height 384 --video-frames 22 --diffusion-steps 20 --cfg 1.0 --output rooftop.mp4

References are only ever scaled down and keep their own aspect ratio, so the output canvas still comes from --width/--height. A plain --image on the Ref2VA checkpoint is treated as a single reference, so clients that only attach β€œan image” β€” the Web UI among them β€” work against it unchanged.

References: stills, clips and soundtracks

Up to nine references, in any mix. --ref-image is a still, referred to in the prompt as <Picture 1>, <Picture 2>, …; --ref-video is a clip β€” a video file or a directory of frames β€” resampled onto H3's own 24 fps and its own canvas, and referred to as <Video 1>, <Video 2>, …; --ref-audio is a soundtrack, resampled to the audio VAE's 32 kHz stereo and truncated to the generated clip's duration. A clip's own sound goes in separately with --ref-video-audio, paired by position with --ref-video, because a container's audio track is not readable through the frame decoder.

# a clip, with its own soundtrack alongside it
dotnet TensorSharp.Cli/bin/TensorSharp.Cli.dll --model models/minimax_h3_ref2va_pruned-Q4_K.gguf \
    --backend ggml_metal --ref-video walk.mp4 --ref-video-audio walk.wav \
    --prompt "the same woman walks along a beach at sunset, wide shot, waves behind her" \
    --width 640 --height 384 --video-frames 22 --diffusion-steps 20 --cfg 1.0 --output beach.mp4

# a still plus a standalone soundtrack
dotnet TensorSharp.Cli/bin/TensorSharp.Cli.dll --model models/minimax_h3_ref2va_pruned-Q4_K.gguf \
    --backend ggml_metal --ref-image singer.jpg --ref-audio song.wav \
    --prompt "she performs on a small club stage under a single spotlight" \
    --width 640 --height 384 --video-frames 22 --diffusion-steps 20 --cfg 1.0 --output stage.mp4

Every reference is extra tokens in the same packed sequence, so the denoiser's cost is linear and easy to predict: a 640Γ—384 reference is 240 tokens and one at 576Γ—448 is 252, and on an RTX 3080 Laptop a 640Γ—384 22-frame denoise step goes from 4.37 s with no references to 9.38 s with eight β€” about 626 ms per reference per step, flat from one to eight. Past about four references it is the Qwen3-VL pass that dominates, not the denoiser: each reference adds roughly 250 vision placeholder tokens to the prompt and that prompt is prefilled through all 50 layers, so two references make a 548-token prompt and eight make 2086 β€” text conditioning grows from ~65 s to ~447 s while the whole 8-step denoise costs ~75 s. Reach for fewer, better references before reaching for fewer steps.

A reference clip is the most expensive input H3 takes: a 22-frame 448Γ—320 one adds 980 conditioning tokens on top of the 1680 the output itself needs, and the VAE has to encode all 22 frames first β€” on an M5 Pro, 14 s to encode and then about 9 s per denoising step against 5 s without it. The language model sees the clip at 2 fps, two frames at a time; the denoiser sees all of it, as latents.

Length, size, and the long-clip ceiling

Width and height round up to a multiple of 32. The frame count rounds up onto the 17k+5 grid β€” 5, 22, 39, 56, 73, 90 … β€” which comes from the video VAE's temporal chunking rather than an arbitrary choice, and fps is pinned to 24 whatever you ask for. Clips of any grid length decode correctly: the VAE runs 5 latent frames at a time with a 2-frame look-ahead and cross-fades the seams. Decoding a long clip in one call instead washes detail out progressively β€” measured against the conditioning photo, frame 0 fell from 0.97 correlation at 22 frames to 0.86 at 90 β€” so the chunking is a correctness requirement, not an optimization. --no-audio skips the audio decode when only the picture is wanted, saving the audio VAE's time and memory.

⚠️

Long clips used to come back black. H3 attends bidirectionally over one unmasked packed sequence, so its key count is the whole clip β€” 2364 tokens at 22 frames, 8646 at 107 β€” and every upstream ggml flash-attention kernel keeps the softmax numerator in FP16 with three bits of headroom. A 107-frame clip overflowed to NaN on the first denoise step and came back with every pixel black and every audio sample clamped, while 73 frames of the same request rendered correctly. h3_attend now pre-scales V by a power of two derived from the key count and undoes it on the output β€” exact, because attention is linear in V β€” and the sampler refuses to save a diverged clip, failing the request and naming the step instead of writing a black file. TS_H3_TRACE=1 prints the latent and velocity magnitudes for every step.

πŸ’‘

The CLI and the server write the audio as a sidecar .wav next to the MP4 rather than muxing it in, because muxing needs an encoder that may not be installed; combine them with ffmpeg -i fox.mp4 -i fox.wav -c:v copy -c:a aac fox_with_audio.mp4. The TensorAgent Mac app writes the same sidecar and also puts the soundtrack inside the MP4 as an AAC track, because Apple's encoder can mux it. Full detail in the MiniMax-H3 card.

Speed, on two machines

The comparison against stable-diffusion.cpp β€” 22 frames, 8 steps, an identical seed, the same weights loaded by both engines β€” comes out differently on the two machines it has been run on, and both are worth having:

22 frames, 8 steps, identical seed256Γ—256640Γ—384
M5 Pro, ggml_metalsd.cpp 49.3 s β†’ TensorSharp 20.9 s (2.4Γ— faster)sd.cpp 108.5 s β†’ TensorSharp 63.1 s (1.7Γ— faster)
RTX 3080 Laptop 16 GB, ggml_cuda, best of 3sd.cpp 37.8 s, TensorSharp 43.6 s (sd.cpp 1.15Γ— faster)sd.cpp 59.8 s, TensorSharp 63.7 s (sd.cpp 1.07Γ— faster)

Per denoise step TensorSharp is ahead on the CUDA machine as well β€” 3.325 s against 3.338 s, taken from the 8-versus-16-step slope β€” so the 3.9 s it gives up at 640Γ—384 is fixed setup cost, and roughly 3 s of that is not inference at all: TensorSharp encodes H.264 where stable-diffusion.cpp writes MJPEG+PCM into an AVI, and a .NET process starts against a native binary. The two machines disagree because the 3080 one is deliberately tight β€” 16 384 MiB of VRAM and 31.7 GB of RAM against a ~35.5 GB model set, so neither the weights nor the page cache fit and setup dominates a one-minute run. Peak VRAM there is 15 780 MiB for TensorSharp against 12 035 MiB for stable-diffusion.cpp, which ran with --auto-fit --stream-layers --diffusion-fa --rng cpu; its default --offload-to-cpu path cannot run this model on that machine at all, because it tries to pin 17.7 GB into 12.3 GB of free RAM.

On a card that cannot hold the model, two things now happen on their own. The denoiser's device residency is handed back before the video VAE loads, whenever the two would not fit together: the finished 10.6 GB denoiser beside the 5.2 GB VAE is 15.8 GB on a 16 GB card, and WDDM does not fail that allocation β€” it silently backs the overflow with host memory, and the whole decode then runs at PCIe speed. Peak VRAM during decode fell from 16 041 MiB to about 5 600 MiB, worth 22 s at 640Γ—384. Separately, the denoiser file is now read through sequentially, pipelined with its own upload: weights are bound as pointers into the mmapped GGUF, so the first upload otherwise faulted every page in from disk from inside the host-to-device copy β€” 0.91 GB/s, the driver's worst case, against 5.97 GB/s once the pages are resident. First denoise step 14.87 s β†’ ~10.2 s. Together they take the RTX 3080 Laptop from 89.0 s to 63.7 s at 640Γ—384 and from 67.2 s to 43.6 s at 256Γ—256, with the prefault's output byte-identical whether it is on or off. The two are gated on different resources β€” the residency handoff on measured free VRAM, the prefault on free host RAM β€” and each steps aside when its resource is plentiful, so a card with room keeps the old behaviour exactly.

VariableDefaultEffect
TS_H3_PHASEoff1 prints a per-stage breakdown β€” encoder open / trunk / teardown, the prefault, every denoise step, VAE open / decode. This is how to see where the wall clock actually goes on your own hardware.
TS_H3_PREFAULT3Denoiser prefault: 0 off, 1 serial, 2 overlapped with text conditioning, 3 pipelined with the upload.
TS_H3_PREFAULT_THREADS1Read streams used for the prefault.
TS_H3_TE_GROUPoffn runs the 50-layer text-encoder trunk in groups of n layers, releasing each group's device copy.
TS_H3_KEEP_RESIDENToff1 keeps the denoiser and both VAEs resident between clips on Metal, the old behaviour. By default they are released before each clip's text encoder: in the TensorAgent Mac app on an M5 Pro that held peak wired memory at 19.1 GB instead of 33.3 GB, for about 1.3 s more text conditioning. Discrete GPUs are unaffected.
πŸ’‘

Measured and rejected on the same RTX 3080 Laptop, so the work is not redone. Text-encoder layer grouping does remove the encoder's own spill (peak 16 041 β†’ 12 981 MiB) and is bit-identical, but it is 3 s slower: the trunk is a one-shot prefill over a ten-token prompt, so each weight is read once and the overflowed ~1.3 GB costs a single PCIe crossing, while grouping still moves all 17 GB and adds allocate/invalidate churn β€” hence off by default, behind TS_H3_TE_GROUP. Overlapping the prefault with text conditioning (TS_H3_PREFAULT=2) is worse than pipelining it with the upload, because the encoder streams its own 17 GB through the same page cache and evicts the pages just placed. Multi-stream prefault loses here too β€” best of three at 640Γ—384 was 63.9 s on 1 stream, 64.9 s on 4 and 66.6 s on 16, with encoder teardown alone rising 2.2 s β†’ 4.0 s β€” because unlike the loader's own warm-up it runs concurrently with the work it is warming. Prefaulting the video VAE is a straight loss: 1.9 s of read for a decode that does not move (9.4 s vs 9.1 s), because that VAE is compute-bound rather than fault-bound. Disabling CUDA graphs changed nothing (65.6 s vs 65.8 s), and neither did TS_GGML_ASYNC_COMPUTE=1 (67.4 s vs 66.2 s), which governs graph submission rather than weight upload. Substituting ffmpeg for the OS H.264 encoder was slower still, at 98.2 s.

The pure-C# CPU path

--backend cpu runs H3 with no ggml at all, through MiniMaxH3Direct{DiT, TextEncoder, VideoVae, AudioVae, VisionEncoder, VideoVaeEncoder3D, AudioVaeEncoder} on the same shared Direct{Context, Linear, Ops} primitives the Wan networks use β€” H3 was the only video model that still lacked one. Every conditioning route works: t2v, i2v, fl2v, and reference images, clips and soundtracks. Everything runs at F32 on the host, so it is there for correctness, portability and machines with no accelerator rather than for speed.

Measured against the GGML path on identical inputs β€” 256Γ—160, 5 frames, one step, a fixed --diffusion-seed. The control column is GGML against itself: its own flash kernel against its explicit-softmax fallback (TS_H3_NO_FLASH=1), which is what makes the other numbers readable.

RouteManaged vs GGML (cosine)Control (cosine)Render PSNR
text encoder alone (64 layers, 32B Q4_K_M)0.99999899β€”β€”
t2v0.9987400.99703231.95 dB (control 28.17 dB)
i2v (3-D VAE encode)0.999897β€”34.87 dB
ref-audio (audio VAE encode)0.999410β€”35.27 dB
ref-image (vision tower)0.9985540.99927528.80 dB (control 30.80 dB)
vision tower output alone0.9999190.999952β€”

Read that honestly: t2v, i2v and reference-audio agree with GGML more closely than GGML’s two attention kernels agree with each other. The vision tower is the exception β€” its residual is roughly 1.4Γ— the control rather than below it. At cosine 0.9999 over 737k elements the tower is structurally correct, but the residual is unexplained, and disabling flash makes the agreement slightly worse, so the F16 K/V cast is not the cause. That is not parity, and it is worth knowing before trusting reference-image conditioning to the managed path for anything precision-sensitive.

The DiT is not bit-identical to GGML and cannot be, because the denoiser amplifies tiny differences. TS_H3_DIT_LAYERS truncates the trunk on both paths and turns that into a curve: the two agree to ~1e-5 through 25 layers, then the difference amplifies non-monotonically β€” 1.15e-3 at depth 40, 1.5e-4 at 44, 1.26e-3 at 50. That shape is what rules out a bug, because an error introduced at some layer would leave the curve non-decreasing for every depth beyond it.

Speed against ggml_cpu on the same clip is mixed rather than uniformly slower: t2v 69 s against 14 s, i2v 70 s against 176 s, reference-audio 63 s against 71 s, reference-image 112 s against 20 s. The vision tower is the expensive managed stage. TS_H3_DUMP_TE, TS_H3_DUMP_VEL_V, TS_H3_DUMP_VEL_A and TS_H3_DUMP_VIS write those tensors to disk so the two paths can be compared on one forward β€” do not compare finished clips instead, because the sampler amplifies whatever the first step differed by. Note also that --seed is the sampling seed: video generation draws its noise from --diffusion-seed, so comparing two runs without it silently compares different noise.

Serving it

On the server, size is a startup flag, because the browser sends none of its own: --video-width 640 --video-height 384 --video-steps 20 is the documented starting point (--width / --height are accepted aliases, and if only one is given H3 takes the other from the conditioning image's aspect ratio). --video-mode pins the conditioning mode for a deployment that offers only one; omit it and every request infers its own. --video-frames, --fps, --video-vae, --video-text-encoder and --audio-vae complete the set. The server has no --cfg at all, which is why the shipped configs set neither steps nor guidance.

dotnet TensorSharp.Server.Host/bin/TensorSharp.Server.Host.dll --model models/minimax_h3_fl2va_pruned-Q4_K.gguf \
    --backend ggml_metal --video-width 640 --video-height 384 --video-steps 20 --video-frames 22

curl -s http://localhost:5000/api/video-generate -H 'content-type: application/json' -d '{
  "prompt": "a red fox trotting through falling snow, cinematic",
  "width": 640, "height": 384, "frames": 22, "steps": 8, "cfg": 1.0,
  "imagePath": "card.jpeg", "videoMode": "i2v"
}'

Three routes accept that body: POST /api/video-generate, POST /api/video-generate/stream (the same thing with SSE progress ticks) and the OpenAI-shaped POST /v1/videos/generations. They share one parser, so videoMode, generateAudio, endImage, referenceImages, referenceVideos, referenceAudios and referenceVideoAudios work on all three, each also accepting a snake_case spelling; referenceVideoAudios pairs by index with referenceVideos. The response carries audioUrl β€” audio_url on the OpenAI route β€” which is null when the model produced no track, and, except on the OpenAI route, audioMuxed, which says whether the MP4 carries the track as well. Every file field names something previously uploaded through /api/upload and is confined to the upload directory. A model rejection (the wrong checkpoint, keyframes together with references) comes back as a 400 carrying the model's own message rather than a generic 500.

GET /api/models answers with a video object β€” null for every non-video model β€” carrying family (minimax-h3), supportsAudio, supportsImageConditioning, supportsEndImageConditioning, supportsReferenceConditioning and maxReferenceImages. That is how the Web UI learns whether to offer a first frame, a last frame, or up to nine references: it enables the reference-attachment control on supportsReferenceConditioning and caps the stills at maxReferenceImages, instead of pattern-matching an architecture string.

Wan 2.1 / 2.2 (prompt β†’ video, image β†’ video)

Download the Turbo DiT, not the base one. The base Wan2.2-TI2V-5B follows the official 50-step Γ— CFG recipe β€” 100 DiT passes per video. The step-distilled Turbo build of the same model is trained to run guidance-free in 4 steps, TensorSharp recognizes it from the file name, and the identical request comes back in minutes instead of hours. The Turbo repository ships only the DiT, so the VAE and the text encoder still come from the base repositories:

# The fast lane: 4 DiT passes instead of 100. Note the underscore in "Wan2_2".
hf download hum-ma/Wan2.2-TI2V-5B-Turbo-GGUF Wan2_2-TI2V-5B-Turbo-Q8_0.gguf --local-dir models
hf download QuantStack/Wan2.2-TI2V-5B-GGUF VAE/Wan2.2_VAE.safetensors --local-dir models
hf download city96/umt5-xxl-encoder-gguf umt5-xxl-encoder-Q8_0.gguf --local-dir models

# text β†’ video
dotnet TensorSharp.Cli/bin/TensorSharp.Cli.dll --model models/Wan2_2-TI2V-5B-Turbo-Q8_0.gguf \
    --video-text-encoder models/umt5-xxl-encoder-Q8_0.gguf --video-vae models/VAE/Wan2.2_VAE.safetensors \
    --prompt "A red fox trotting through falling snow, cinematic" \
    --video-frames 81 --fps 24 --output out.mp4 --backend ggml_cuda
# image β†’ video (the image becomes the first frame; Wan 2.2 models only)
dotnet TensorSharp.Cli/bin/TensorSharp.Cli.dll --model models/Wan2_2-TI2V-5B-Turbo-Q8_0.gguf \
    --video-text-encoder models/umt5-xxl-encoder-Q8_0.gguf --video-vae models/VAE/Wan2.2_VAE.safetensors \
    --image first_frame.png --prompt "the camera pushes in as the waves rise" \
    --output out.mp4 --backend ggml_cuda
dotnet TensorSharp.Server.Host/bin/TensorSharp.Server.Host.dll --model models/Wan2_2-TI2V-5B-Turbo-Q8_0.gguf \
    --video-text-encoder models/umt5-xxl-encoder-Q8_0.gguf --video-vae models/VAE/Wan2.2_VAE.safetensors \
    --backend ggml_cuda --video-frames 121 --fps 24

At load the console confirms the detection with step-distilled checkpoint detected -> 4 steps, guidance off (--diffusion-steps / --cfg override). Measured on an M5 Pro with ggml_metal, a 1088Γ—832 Γ— 121-frame (5 s, 720p-class) image-to-video request costs 100 DiT passes β‰ˆ 3 h 30 m on the base checkpoint and 4 passes = 17 m 30 s on the Turbo one β€” the same flags, the same resolution, only the --model path differs. At 480p (736Γ—544 Γ— 121f) the Turbo checkpoint finishes in 6 m 19 s.

To reproduce the base recipe instead β€” for a reference sample, or on a Wan 2.1 checkpoint that has no distilled build β€” swap the first download and the --model paths:

hf download QuantStack/Wan2.2-TI2V-5B-GGUF Wan2.2-TI2V-5B-Q8_0.gguf --local-dir models

# 50 steps x 2 CFG passes; --cfg-cache-stride trades a little accuracy for 1.30x / 1.43x
dotnet TensorSharp.Cli/bin/TensorSharp.Cli.dll --model models/Wan2.2-TI2V-5B-Q8_0.gguf \
    --video-text-encoder models/umt5-xxl-encoder-Q8_0.gguf --video-vae models/VAE/Wan2.2_VAE.safetensors \
    --prompt "A red fox trotting through falling snow, cinematic" \
    --video-frames 81 --fps 24 --cfg-cache-stride 2 --output out.mp4 --backend ggml_cuda

For Wan 2.2 A14B image-to-video, download both distilled experts under one --local-dir β€” the loader finds the low-noise partner from the high-noise file name, so only one --model path is passed. A14B uses the Wan 2.1 VAE:

hf download jayn7/WAN2.2-I2V_A14B-DISTILL-LIGHTX2V-4STEP-GGUF \
    high_noise/wan2.2_i2v_A14b_high_noise_lightx2v_4step-Q4_K_M.gguf --local-dir models
hf download jayn7/WAN2.2-I2V_A14B-DISTILL-LIGHTX2V-4STEP-GGUF \
    low_noise/wan2.2_i2v_A14b_low_noise_lightx2v_4step-Q4_K_M.gguf --local-dir models
hf download QuantStack/Wan2.2-I2V-A14B-GGUF VAE/Wan2.1_VAE.safetensors --local-dir models

dotnet TensorSharp.Cli/bin/TensorSharp.Cli.dll \
    --model models/high_noise/wan2.2_i2v_A14b_high_noise_lightx2v_4step-Q4_K_M.gguf \
    --video-text-encoder models/umt5-xxl-encoder-Q8_0.gguf --video-vae models/VAE/Wan2.1_VAE.safetensors \
    --image first_frame.png --prompt "the camera pushes in as the waves rise" \
    --output out.mp4 --backend ggml_cuda
⚑

Generate at a resolution Wan was trained at. Wan is trained at 480p (832Γ—480) and 720p (1280Γ—704), and TI2V-5B is natively a 720p model. Below ~0.3 MP the DiT is out of distribution and the video gets soft, unstable and color-drifting no matter how many steps you spend β€” the pipeline warns when you cross that line. Generate at 480p and downscale afterwards rather than generating small.

Wan companions, cost and resolution

Wan turns a prompt β€” and, on the Wan 2.2 checkpoints, an uploaded first frame β€” into an H.264 MP4. The --model GGUF is only the DiT; TensorSharp resolves two companions next to it (a VAE/, HighNoise/ or LowNoise/ subfolder is fine):

FamilyLatentModesNotes
Wan 2.1 T2V (1.3B / 14B)16 ch, 8Γ—8Γ—4text β†’ videosingle DiT
Wan 2.2 TI2V-5B48 ch, 16Γ—16Γ—4text β†’ video, image β†’ videodense 5B, 24 fps, 720p-capable
Wan 2.2 A14B (T2V / I2V)16 ch (36 ch I2V input)text β†’ video, image β†’ videotwo 14B experts switched at a timestep boundary

Each denoise step is one resident-weight ggml graph (CUDA-graph-captured, flash attention, per-token-timestep modulation for TI2V image-to-video); the video VAE encode and decode are each a single graph. The stages hand VRAM over in sequence β€” text encoder, then DiT, then VAE β€” so peak usage is roughly max(TE, DiT + attention, VAE): TI2V-5B generates 81-frame 480p image-to-video on a 16 GB GPU in under 8 minutes, and both A14B experts run sequentially on the same card. Numerics are verified against diffusers (DiT cosine > 0.995, VAE encoders > 0.999, decode 59.9 dB PSNR), and Wan 2.1 runs 6.0Γ— faster end-to-end than stable-diffusion.cpp on the identical workload.

The checkpoint decides the wall clock

Wan's DiT token count is latent_frames Γ— (h/2) Γ— (w/2) and self-attention costs O(tokensΒ²), so a 5-second 720p video is a genuinely large job: 1088Γ—832 Γ— 121 frames is 27 404 tokens, and the official TI2V-5B recipe spends 50 steps Γ— 2 classifier-free-guidance passes = 100 DiT passes on it. A step-distilled checkpoint is trained to run guidance-free in 4 steps, so the same video costs 4 passes β€” 1/25th of the denoising work. TensorSharp detects one from the DiT file name (turbo, distill, lightning, lightx2v, fastwan, -dmd, or an explicit …-4steps-… for 1–16 steps), prints step-distilled checkpoint detected -> 4 steps, guidance off at load, and applies the recipe itself; --diffusion-steps and --cfg still override.

M5 Pro, ggml_metal, TI2V-5B Q8_0, 1088Γ—832 Γ— 121f (27 404 tokens, image β†’ video)Base, beforeBase, nowTurbo, now
DiT passes100 (50 steps Γ— CFG)1004 (guidance-free)
per pass206.2 s120.2 s120.2 s
denoise total20 615 s12 020 s481 s
VAE decode, 121 frames863 s563 s563 s
end to endβ‰ˆ 5 h 58 mβ‰ˆ 3 h 30 m17 m 30 s

The two levers are independent: ~1.7Γ— per pass from keeping the DiT's flash-attention keys and values in F16 (2.02Γ— on a single 27 k-token self-attention, at cosine 0.999964 against the diffusers reference β€” TS_WAN_DIT_KV_F16=0 restores F32) and from running the Metal VAE convolutions through MPSGraph instead of ggml's im2col+GEMM lowering (VAE decode 159 s β†’ 80 s at 736Γ—544 Γ— 81f, numerics unchanged at 93.9 dB PSNR β€” TS_WAN_VAE_MPS_CONV=0 reverts), and 25Γ— fewer passes from the distilled checkpoint. Once distilled, the VAE decode is the bottleneck at ~55% of the run, not the DiT.

Frames and resolution set the rest

Output (M5 Pro, same Turbo checkpoint and image)TokensDenoiseVAE decodeTotal
736Γ—544 Γ— 81f (3.4 s, 480p class)8 21184 s159 s4 m 09 s
736Γ—544 Γ— 121f (5 s, 480p class)12 121137 s237 s6 m 19 s
1088Γ—832 Γ— 121f (5 s, 720p class)27 404481 s563 s17 m 30 s

480p (β‰ˆ0.4 MP) is a resolution Wan is trained at, so the first two rows are in-distribution rather than a degraded mode β€” that is the setting to reach for when a few minutes matters. Quality falls off below ~0.3 MP, where the DiT is out of distribution and the video turns soft, unstable and color-drifting however many steps you spend; the pipeline prints a warning when you cross that line. Generate at a supported resolution and downscale afterwards (--width 480 --height 704 rather than 320Γ—480).

To make a large request cheaper, in order of effect: (1) use a step-distilled checkpoint β€” 100 passes become 4, dwarfing everything else; (2) fewer frames β€” 121 β†’ 61 roughly quarters the attention work and halves the VAE decode; (3) a smaller frame area, but not below ~0.3 MP; (4) fewer steps, on base checkpoints only β€” 30 instead of 50 is visibly close and 1.7Γ— cheaper; (5) --cfg-cache-stride 2 or 3, which runs the unconditional pass on one step in N and reuses the cached guidance direction between β€” 77 of the 100 passes (1.30Γ—) or 70 (1.43Γ—) at 50 steps. It is an approximation, so leave it off when matching a reference sample matters, and it does nothing on a distilled checkpoint, which is already guidance-free.

On NVIDIA, ggml_cuda is the fastest Wan backend: on an RTX 2000 Ada 16 GB running the official 480p Wan2.1-1.3B recipe it takes 12.0 s/step against ggml_vulkan's 17.2 and the direct cuda backend's 19.3. Wan does not run on mlx at all, and the cpu / ggml_cpu backends are for functional use only.

Drive it from the CLI (--prompt, optional --image, --video-frames, --fps, --flow-shift, --sampler, --negative-prompt), the HTTP API (/v1/videos/generations), or the Web UI chat with an image upload. On the server, --video-frames and --fps set defaults rather than caps β€” a request carrying its own frames or fps overrides them independently, and with both omitted the model's own recipe applies (49 frames at 24 fps for Wan2.2-TI2V, 33 at 16 fps otherwise). Frame counts snap to the VAE's temporal grid (4k+1). Full details are in the repository's docs/models/wan.md card.