Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion DEVELOPMENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ See Microsoft's [cross-platform .NET installation overview](https://learn.micros
- **`git` and network access:** the GGML/CUDA native builds clone the ggml sources from [github.com/ggml-org/ggml](https://github.com/ggml-org/ggml) into `ExternalProjects/ggml/` on first build (see `eng/fetch-ggml.sh` / `eng/fetch-ggml.ps1`). The clone tracks ggml's default branch (`master`); pin a different ref with `TENSORSHARP_GGML_GIT_REF`, or set `TENSORSHARP_GGML_NO_UPDATE=1` to skip the network update once cloned (offline rebuilds)
- **macOS (Metal backend):** CMake 3.20+ and the Xcode command-line tools for building the native GGML library — it embeds its Metal kernels as source and compiles them at run time, so it needs no Metal compiler at build time. The MLX backend additionally builds `libmlxc` from `TensorSharp.Backends.MLX/Native/` via `bash TensorSharp.Backends.MLX/build-native-macos.sh`, and that build *does* compile Metal shaders, so it needs a **full Xcode plus the Metal toolchain** — the command-line tools alone are not enough. `eng/ensure-metal-toolchain.sh` provisions this automatically on first build; see [Build the native MLX library](#build-the-native-mlx-library-macos-only)
- **Windows (GGML CPU / CUDA backends):** CMake 3.20+ and Visual Studio 2022 or 2026 C++ build tools; for `ggml_cuda` or `cuda`, install an NVIDIA driver plus CUDA Toolkit 12.x or another compatible CUDA toolkit with cuBLAS. With Visual Studio 2026, whose MSVC 14.5x toolset is newer than current CUDA toolkits officially accept as a host compiler, the build passes `-allow-unsupported-compiler` to `nvcc` automatically; include the "C++ CMake tools for Windows" component so the build can use the Ninja generator (the Visual Studio generator additionally needs a CUDA toolkit that ships MSBuild integration for your VS version)
- **Linux (GGML CPU / CUDA backends):** CMake 3.20+; for `ggml_cuda` or `cuda`, install an NVIDIA driver plus CUDA Toolkit 12.x or another compatible CUDA toolkit with cuBLAS
- **Linux (GGML CPU / CUDA backends):** CMake 3.20+; for `ggml_cuda` or `cuda`, install an NVIDIA driver plus CUDA Toolkit 12.x or another compatible CUDA toolkit with cuBLAS. **cuDNN is optional**: when its headers and library are found (`libcudnn9-dev-cuda-12`, or any package providing `cudnn.h` + `libcudnn`), the Wan video VAE runs its convolutions through cuDNN instead of ggml's im2col+GEMM lowering, which is ~1.75x on the decode; without it the build succeeds unchanged and the VAE keeps the ggml path. The configure step prints which one applies
- **Windows (GGML Vulkan backend):** enabled automatically when the machine has a Vulkan runtime (`System32\vulkan-1.dll`, shipped by every recent GPU driver). With a [LunarG Vulkan SDK](https://vulkan.lunarg.com/) installed it is used directly; without one the build auto-provisions a portable toolchain (Vulkan-Headers, a vulkan-1 import library generated from the system loader, glslc, SPIRV-Headers) into `ExternalProjects/vulkan-toolchain/` via `eng/fetch-vulkan-toolchain.ps1`. Opt out with `build-windows.ps1 --no-vulkan` or `TENSORSHARP_GGML_NATIVE_ENABLE_VULKAN=OFF`. A GPU driver with Vulkan 1.3 support is required at runtime
- **Linux (GGML Vulkan backend):** enabled automatically when a Vulkan loader (`libvulkan.so.1`) is installed. Distro dev packages are used when present (`apt install libvulkan-dev glslc spirv-headers`); otherwise the build auto-provisions the missing pieces (Vulkan-Headers, glslc from the shaderc CI prebuilts, SPIRV-Headers) into `ExternalProjects/vulkan-toolchain/` via `eng/fetch-vulkan-toolchain.sh`. Opt out with `build-linux.sh --no-vulkan` or `TENSORSHARP_GGML_NATIVE_ENABLE_VULKAN=OFF`
- GGUF model files (e.g., from [Hugging Face](https://huggingface.co))
Expand Down
12 changes: 7 additions & 5 deletions FEATURES.md

Large diffs are not rendered by default.

13 changes: 8 additions & 5 deletions FEATURES_zh-cn.md

Large diffs are not rendered by default.

19 changes: 15 additions & 4 deletions InferenceWeb.Tests/WanVideoOracleTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -17,15 +17,25 @@
using TensorSharp.Models.WanVideo;
using TensorSharp.Runtime;
using Xunit;
using Xunit.Abstractions;

namespace InferenceWeb.Tests
{
public class WanVideoOracleTests
{
private const string FixtureDir = @"C:\Works\models\wan\fixtures";
private const string Vae22Path = @"C:\Works\models\wan\VAE\Wan2.2_VAE.safetensors";
private const string Vae21Path = @"C:\Works\models\wan\wan_2.1_vae.safetensors";
private const string Ti2vGguf = @"C:\Works\models\wan\Wan2.2-TI2V-5B-Q8_0.gguf";
private readonly ITestOutputHelper _output;
public WanVideoOracleTests(ITestOutputHelper output) { _output = output; }

// TS_WAN_MODEL_DIR points the whole oracle suite at a local Wan checkout
// (the fixtures live in <dir>/fixtures); without it the historical Windows
// paths apply, so an existing checkout keeps running unchanged and the
// tests still no-op wherever the files are absent.
private static readonly string ModelDir =
Environment.GetEnvironmentVariable("TS_WAN_MODEL_DIR") ?? @"C:\Works\models\wan";
private static readonly string FixtureDir = Path.Combine(ModelDir, "fixtures");
private static readonly string Vae22Path = Path.Combine(ModelDir, "VAE", "Wan2.2_VAE.safetensors");
private static readonly string Vae21Path = Path.Combine(ModelDir, "wan_2.1_vae.safetensors");
private static readonly string Ti2vGguf = Path.Combine(ModelDir, "Wan2.2-TI2V-5B-Q8_0.gguf");

private static float[] ReadF32(string name)
{
Expand Down Expand Up @@ -229,6 +239,7 @@ public void Dit5bMatchesDiffusersUniformAndMasked()
var vmLat = new float[xLat.Length];
WanVideoPipeline.Unpatchify(vm, vmLat, T, Hh, Ww, 48, dit.OutTok);
double cosM = Cosine(vmLat, ReadF32("dit_v_masked.bin"));
_output.WriteLine($"[wan-oracle] TI2V-5B DiT cosine vs diffusers: uniform={cosU:F6} masked={cosM:F6}");
Assert.True(cosM > 0.995, $"TI2V-5B DiT (masked t) cosine vs diffusers = {cosM:F6} (want > 0.995)");
}
}
Expand Down
171 changes: 171 additions & 0 deletions InferenceWeb.Tests/WanVideoTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,177 @@ public void SnapDimRoundsToSixteen(int input, int expected)
public void SnapFramesRoundsToVaeTemporalGrid(int input, int expected)
=> Assert.Equal(expected, WanVideoPipeline.SnapFrames(input));

// ---- degenerate-decode guard ------------------------------------------

private static TensorSharp.Models.QwenImage.RgbImage MakeFrame(int w, int h, Func<int, float> pixel)
{
var chw = new float[3 * w * h];
for (int i = 0; i < chw.Length; i++) chw[i] = pixel(i);
return TensorSharp.Models.QwenImage.RgbImage.FromPlanarChw(w, h, chw);
}

[Fact]
public void FlatDecodeIsRejected()
{
// Every frame a single colour = the Metal tensor-API VAE corruption.
var frames = new[] { MakeFrame(8, 8, _ => 0f), MakeFrame(8, 8, _ => 0f), MakeFrame(8, 8, _ => 0f) };
var ex = Assert.Throws<InvalidOperationException>(
() => WanVideoPipeline.AssertFramesAreNotDegenerate(frames));
Assert.Contains("TS_WAN_METAL_TENSOR_API=0", ex.Message);
}

[Fact]
public void NaNDecodeIsRejected()
{
var frames = new[] { MakeFrame(8, 8, _ => float.NaN), MakeFrame(8, 8, _ => float.NaN) };
Assert.Throws<InvalidOperationException>(
() => WanVideoPipeline.AssertFramesAreNotDegenerate(frames));
}

[Fact]
public void RealDecodeIsAccepted()
{
var frames = new[] { MakeFrame(8, 8, i => (i % 97) / 97f), MakeFrame(8, 8, i => (i % 53) / 53f) };
WanVideoPipeline.AssertFramesAreNotDegenerate(frames); // must not throw
}

[Fact]
public void ASingleFlatFrameIsNotEnoughToReject()
{
// A legitimately flat frame (a fade to black at the end) must not fail
// the whole video — the guard only fires when every sampled frame is flat.
var frames = new[]
{
MakeFrame(8, 8, i => (i % 97) / 97f), MakeFrame(8, 8, i => (i % 53) / 53f),
MakeFrame(8, 8, i => (i % 31) / 31f), MakeFrame(8, 8, _ => 0f),
};
WanVideoPipeline.AssertFramesAreNotDegenerate(frames);
}

// ---- step-distilled checkpoint detection -------------------------------

[Theory]
// Published distilled Wan checkpoints / LoRAs.
[InlineData("Wan2_2-TI2V-5B-Turbo-Q8_0.gguf", 4)]
[InlineData("Wan22_TI2V_5B_Turbo_lora_rank_64_fp16.safetensors", 4)]
[InlineData("Wan2_2_5B_FastWanFullAttn_lora_rank_128_bf16.safetensors", 4)]
[InlineData("Wan2.2-I2V-A14B-HighNoise-Lightning-4steps-Q4_K_M.gguf", 4)]
[InlineData("wan2.2_i2v_A14b_low_noise_lora_rank64_lightx2v_4step.safetensors", 4)]
[InlineData("Wan2.1-T2V-14B-StepDistill-CfgDistill-Lightx2v-8steps.gguf", 8)]
// Ordinary checkpoints must NOT be mistaken for distilled ones.
[InlineData("Wan2.2-TI2V-5B-Q8_0.gguf", 0)]
[InlineData("wan2.1-t2v-14b-Q4_K_M.gguf", 0)]
[InlineData("Wan2.1-T2V-1.3B-F16.gguf", 0)]
[InlineData("", 0)]
public void DistilledStepsParsedFromCheckpointName(string fileName, int expected)
=> Assert.Equal(expected, WanVideoModel.ParseDistilledSteps(fileName));

[Fact]
public void DistilledStepCountIsBoundedToASaneRange()
{
// A "1.3B" or a Q4_K_M shard index must not be read as a step count, and
// an absurd count falls back to the marker default rather than trusting it.
Assert.Equal(0, WanVideoModel.ParseDistilledSteps("Wan2.1-T2V-1.3B-F16.gguf"));
Assert.Equal(4, WanVideoModel.ParseDistilledSteps("wan-turbo-9999steps.gguf"));
}

// ---- VAE decode band layout -------------------------------------------

[Theory]
// (latent rows, plane width px, spatial scale) — TI2V-5B (16x) and Wan 2.1 (8x)
[InlineData(52, 1088, 16)] // 1088x832, the 121-frame 720p-class I2V shape
[InlineData(44, 1280, 16)] // 1280x704, the official TI2V 720p recipe
[InlineData(80, 1920, 16)]
[InlineData(120, 1664, 8)]
public void VaeBandsCoverThePlaneWithinTheMemoryBudget(int lh, int w, int scale)
{
const long threshold = 640_000;
var starts = WanVaeBase.PlanBands(lh, w, scale, threshold, out int bandLat);
Assert.NotNull(starts);
Assert.True(starts.Count >= 2);

// every band fits the per-band pixel budget
Assert.True((long)w * bandLat * scale <= threshold,
$"band {w}x{bandLat * scale} exceeds the {threshold} px budget");
// the plane is fully covered, first band at the top, last flush with lh
Assert.Equal(0, starts[0]);
Assert.Equal(lh - bandLat, starts[^1]);
for (int i = 1; i < starts.Count; i++)
{
Assert.True(starts[i] > starts[i - 1], "band starts must advance");
int overlap = starts[i - 1] + bandLat - starts[i];
Assert.True(overlap >= WanVaeBase.OverlapLat,
$"seam {i} overlaps {overlap} rows, want >= {WanVaeBase.OverlapLat}");
}
}

[Fact]
public void VaeBandsDecodeFewerRowsThanTheFixedHeightWalk()
{
// 1088x832 (lh 52): the old fixed-height walk used a 24-row band at
// starts 0/16/28 — 72 rows of work for 52 rows of output.
const long threshold = 640_000;
var starts = WanVaeBase.PlanBands(52, 1088, 16, threshold, out int bandLat);
Assert.Equal(2, starts.Count);
Assert.Equal(30, bandLat);
Assert.Equal(60, starts.Count * bandLat); // was 72
}

[Fact]
public void VaeSkipsTilingWhenThePlaneFitsWhole()
{
// 832x480 on the TI2V VAE is 30 latent rows — under the budget, one graph.
Assert.Null(WanVaeBase.PlanBands(30, 832, 16, 640_000, out int bandLat));
Assert.Equal(30, bandLat);
}

// ---- guidance cache ---------------------------------------------------

[Theory]
[InlineData(0)]
[InlineData(1)]
public void GuidanceCacheOffRunsBothPassesEveryStep(int stride)
{
for (int i = 0; i < 50; i++)
Assert.True(WanVideoPipeline.UsesUncondPass(i, 50, stride));
}

[Theory]
[InlineData(2, 50, 27)] // 3 warm-up + every 2nd of steps 3..49 (which includes the last)
[InlineData(3, 50, 20)] // 3 warm-up + 16 strided + the last
[InlineData(4, 50, 16)]
public void GuidanceCacheStrideSkipsTheExpectedUncondPasses(int stride, int steps, int expected)
{
int uncond = 0;
for (int i = 0; i < steps; i++)
if (WanVideoPipeline.UsesUncondPass(i, steps, stride)) uncond++;
Assert.Equal(expected, uncond);
}

[Fact]
public void GuidanceCacheAlwaysRecomputesWarmupAndFinalStep()
{
const int steps = 50, stride = 4;
// The steps that decide structure (the first few) and the one that
// produces the final latent must never run on a stale guidance delta.
for (int i = 0; i < WanVideoPipeline.CfgCacheWarmup; i++)
Assert.True(WanVideoPipeline.UsesUncondPass(i, steps, stride));
Assert.True(WanVideoPipeline.UsesUncondPass(steps - 1, steps, stride));
// ... and something in the middle must actually be skipped, or the
// cache would be a no-op.
Assert.False(WanVideoPipeline.UsesUncondPass(WanVideoPipeline.CfgCacheWarmup + 1, steps, stride));
}

// ---- progress / ETA formatting ----------------------------------------

[Theory]
[InlineData(-1, "ETA unknown")]
[InlineData(43, "~43s")]
[InlineData(432, "~7m 12s")]
[InlineData(3840, "~1h 04m")]
public void FormatEtaIsHumanReadable(double seconds, string expected)
=> Assert.Equal(expected, WanVideoPipeline.FormatEta(seconds));

// ---- RoPE -------------------------------------------------------------

[Fact]
Expand Down
Loading
Loading