From 526dae46a5f9f410840952c0d83e44d3006cd747 Mon Sep 17 00:00:00 2001 From: axi Date: Thu, 30 Jul 2026 20:34:37 +0300 Subject: [PATCH] Add Stable Diffusion hosting integration with Hugging Face model support - Implement `CommunityToolkit.Aspire.Hosting.StableDiffusionCpp` for integrating the `stable-diffusion.cpp` container. - Introduce support for official image variants (`Cuda`, `CudaSpark`, `Vulkan`, `Sycl`, `Musa`) with GPU configurations. - Add example projects demonstrating the usage of Stable Diffusion resources. - Implement Hugging Face model resource integration with token-based authentication and download support. - Include tests to validate container configurations, runtime arguments, endpoints, and Hugging Face model interactions. - Add extensive XML documentation and a `README.md` for library usage and guidance. - Register the package in solution and centralize dependency management. --- CommunityToolkit.Aspire.slnx | 5 + README.md | 3 + .../.gitignore | 1 + ....Hosting.StableDiffusionCpp.AppHost.csproj | 12 + .../Program.cs | 12 + .../Properties/launchSettings.json | 14 ++ ...t.Aspire.Hosting.StableDiffusionCpp.csproj | 17 ++ .../HuggingFaceModelDownloader.cs | 196 +++++++++++++++ .../README.md | 65 +++++ .../StableDiffusionCppContainerImageTags.cs | 12 + .../StableDiffusionCppImageVariant.cs | 22 ++ .../StableDiffusionCppModelResource.cs | 61 +++++ .../StableDiffusionCppResource.cs | 80 ++++++ ...leDiffusionCppResourceBuilderExtensions.cs | 233 ++++++++++++++++++ .../AddStableDiffusionCppTests.cs | 206 ++++++++++++++++ ...re.Hosting.StableDiffusionCpp.Tests.csproj | 7 + 16 files changed, 946 insertions(+) create mode 100644 examples/stable-diffusion-cpp/CommunityToolkit.Aspire.Hosting.StableDiffusionCpp.AppHost/.gitignore create mode 100644 examples/stable-diffusion-cpp/CommunityToolkit.Aspire.Hosting.StableDiffusionCpp.AppHost/CommunityToolkit.Aspire.Hosting.StableDiffusionCpp.AppHost.csproj create mode 100644 examples/stable-diffusion-cpp/CommunityToolkit.Aspire.Hosting.StableDiffusionCpp.AppHost/Program.cs create mode 100644 examples/stable-diffusion-cpp/CommunityToolkit.Aspire.Hosting.StableDiffusionCpp.AppHost/Properties/launchSettings.json create mode 100644 src/CommunityToolkit.Aspire.Hosting.StableDiffusionCpp/CommunityToolkit.Aspire.Hosting.StableDiffusionCpp.csproj create mode 100644 src/CommunityToolkit.Aspire.Hosting.StableDiffusionCpp/HuggingFaceModelDownloader.cs create mode 100644 src/CommunityToolkit.Aspire.Hosting.StableDiffusionCpp/README.md create mode 100644 src/CommunityToolkit.Aspire.Hosting.StableDiffusionCpp/StableDiffusionCppContainerImageTags.cs create mode 100644 src/CommunityToolkit.Aspire.Hosting.StableDiffusionCpp/StableDiffusionCppImageVariant.cs create mode 100644 src/CommunityToolkit.Aspire.Hosting.StableDiffusionCpp/StableDiffusionCppModelResource.cs create mode 100644 src/CommunityToolkit.Aspire.Hosting.StableDiffusionCpp/StableDiffusionCppResource.cs create mode 100644 src/CommunityToolkit.Aspire.Hosting.StableDiffusionCpp/StableDiffusionCppResourceBuilderExtensions.cs create mode 100644 tests/CommunityToolkit.Aspire.Hosting.StableDiffusionCpp.Tests/AddStableDiffusionCppTests.cs create mode 100644 tests/CommunityToolkit.Aspire.Hosting.StableDiffusionCpp.Tests/CommunityToolkit.Aspire.Hosting.StableDiffusionCpp.Tests.csproj diff --git a/CommunityToolkit.Aspire.slnx b/CommunityToolkit.Aspire.slnx index a58bb4ef6..0ce1f2da3 100644 --- a/CommunityToolkit.Aspire.slnx +++ b/CommunityToolkit.Aspire.slnx @@ -214,6 +214,9 @@ + + + @@ -288,6 +291,7 @@ + @@ -363,6 +367,7 @@ + diff --git a/README.md b/README.md index 7d3a49d85..e8d027a31 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,7 @@ This repository contains the source code for the Aspire Community Toolkit, a col | - **Learn More**: [`Hosting.Kind`][kind-integration-docs]
- Stable 📦: [![CommunityToolkit.Aspire.Hosting.Kind][kind-shields]][kind-nuget]
- Preview 📦: [![CommunityToolkit.Aspire.Hosting.Kind][kind-shields-preview]][kind-nuget-preview] | A hosting integration for local Kind (Kubernetes in Docker) clusters and Kind-backed Aspire deploy/publish workflows. | | - **Learn More**: [`Hosting.NodeJS.Extensions`][nodejs-ext-integration-docs]
- Stable 📦: [![CommunityToolkit.Aspire.NodeJS.Extensions][nodejs-ext-shields]][nodejs-ext-nuget]
- Preview 📦: [![CommunityToolkit.Aspire.Hosting.JavaScript.Extensions][nodejs-ext-shields-preview]][nodejs-ext-nuget-preview] | An integration that contains some additional extensions for running Node.js applications | | - **Learn More**: [`Hosting.Ollama`][ollama-integration-docs]
- Stable 📦: [![CommunityToolkit.Aspire.Hosting.Ollama][ollama-shields]][ollama-nuget]
- Preview 📦: [![CommunityToolkit.Aspire.Hosting.Ollama][ollama-shields-preview]][ollama-nuget-preview] | An Aspire hosting integration leveraging the [Ollama](https://ollama.com) container with support for downloading a model on startup. | +| - **Learn More**: [`Hosting.StableDiffusionCpp`](./src/CommunityToolkit.Aspire.Hosting.StableDiffusionCpp/README.md)
- Preview 📦: [![CommunityToolkit.Aspire.Hosting.StableDiffusionCpp][stable-diffusion-cpp-shields-preview]][stable-diffusion-cpp-nuget-preview] | An Aspire hosting integration for official [stable-diffusion.cpp](https://github.com/leejet/stable-diffusion.cpp) images with Hugging Face model downloads. | | - **Learn More**: [`OllamaSharp`][ollama-integration-docs]
- Stable 📦: [![CommunityToolkit.Aspire.OllamaSharp][ollamasharp-shields]][ollamasharp-nuget]
- Preview 📦: [![CommunityToolkit.Aspire.OllamaSharp][ollamasharp-shields-preview]][ollamasharp-nuget-preview] | An Aspire client integration for the [OllamaSharp](https://github.com/awaescher/OllamaSharp) package. | | - **Learn More**: [`Hosting.Meilisearch`][meilisearch-integration-docs]
- Stable 📦: [![CommunityToolkit.Aspire.Hosting.Meilisearch][meilisearch-shields]][meilisearch-nuget]
- Preview 📦: [![CommunityToolkit.Aspire.Hosting.Meilisearch][meilisearch-shields-preview]][meilisearch-nuget-preview] | An Aspire hosting integration leveraging the [Meilisearch](https://meilisearch.com) container. | | - **Learn More**: [`Meilisearch`][meilisearch-integration-docs]
- Stable 📦: [![CommunityToolkit.Aspire.Meilisearch][meilisearch-client-shields]][meilisearch-client-nuget]
- Preview 📦: [![CommunityToolkit.Aspire.Meilisearch][meilisearch-client-shields-preview]][meilisearch-client-nuget-preview] | An Aspire client integration for the [Meilisearch](https://github.com/meilisearch/meilisearch-dotnet) package. | @@ -304,6 +305,8 @@ This project is supported by the [.NET Foundation](https://dotnetfoundation.org) [rustfs-hosting-nuget]: https://nuget.org/packages/CommunityToolkit.Aspire.Hosting.RustFs/ [rustfs-hosting-shields-preview]: https://img.shields.io/nuget/vpre/CommunityToolkit.Aspire.Hosting.RustFs?label=nuget%20(preview) [rustfs-hosting-nuget-preview]: https://nuget.org/packages/CommunityToolkit.Aspire.Hosting.RustFs/absoluteLatest +[stable-diffusion-cpp-shields-preview]: https://img.shields.io/nuget/vpre/CommunityToolkit.Aspire.Hosting.StableDiffusionCpp?label=nuget%20(preview) +[stable-diffusion-cpp-nuget-preview]: https://nuget.org/packages/CommunityToolkit.Aspire.Hosting.StableDiffusionCpp/absoluteLatest [surrealdb-integration-docs]: https://learn.microsoft.com/dotnet/aspire/community-toolkit/hosting-surrealdb [surrealdb-shields]: https://img.shields.io/nuget/v/CommunityToolkit.Aspire.Hosting.SurrealDb [surrealdb-nuget]: https://nuget.org/packages/CommunityToolkit.Aspire.Hosting.SurrealDb/ diff --git a/examples/stable-diffusion-cpp/CommunityToolkit.Aspire.Hosting.StableDiffusionCpp.AppHost/.gitignore b/examples/stable-diffusion-cpp/CommunityToolkit.Aspire.Hosting.StableDiffusionCpp.AppHost/.gitignore new file mode 100644 index 000000000..21f4c9bb6 --- /dev/null +++ b/examples/stable-diffusion-cpp/CommunityToolkit.Aspire.Hosting.StableDiffusionCpp.AppHost/.gitignore @@ -0,0 +1 @@ +/.stable-diffusion-cpp/ diff --git a/examples/stable-diffusion-cpp/CommunityToolkit.Aspire.Hosting.StableDiffusionCpp.AppHost/CommunityToolkit.Aspire.Hosting.StableDiffusionCpp.AppHost.csproj b/examples/stable-diffusion-cpp/CommunityToolkit.Aspire.Hosting.StableDiffusionCpp.AppHost/CommunityToolkit.Aspire.Hosting.StableDiffusionCpp.AppHost.csproj new file mode 100644 index 000000000..d94e0cc3e --- /dev/null +++ b/examples/stable-diffusion-cpp/CommunityToolkit.Aspire.Hosting.StableDiffusionCpp.AppHost/CommunityToolkit.Aspire.Hosting.StableDiffusionCpp.AppHost.csproj @@ -0,0 +1,12 @@ + + + + Exe + communitytoolkit-aspire-stable-diffusion-cpp + + + + + + + diff --git a/examples/stable-diffusion-cpp/CommunityToolkit.Aspire.Hosting.StableDiffusionCpp.AppHost/Program.cs b/examples/stable-diffusion-cpp/CommunityToolkit.Aspire.Hosting.StableDiffusionCpp.AppHost/Program.cs new file mode 100644 index 000000000..5ccf55e68 --- /dev/null +++ b/examples/stable-diffusion-cpp/CommunityToolkit.Aspire.Hosting.StableDiffusionCpp.AppHost/Program.cs @@ -0,0 +1,12 @@ +var builder = DistributedApplication.CreateBuilder(args); + +builder.AddStableDiffusionCpp( + "stable-diffusion", + StableDiffusionCppImageVariant.Cuda) + .WithGPUSupport() + .WithVaeTiling() + .AddHuggingFaceModel( + "stabilityai/stable-diffusion-xl-base-1.0", + "sd_xl_base_1.0.safetensors"); + +builder.Build().Run(); diff --git a/examples/stable-diffusion-cpp/CommunityToolkit.Aspire.Hosting.StableDiffusionCpp.AppHost/Properties/launchSettings.json b/examples/stable-diffusion-cpp/CommunityToolkit.Aspire.Hosting.StableDiffusionCpp.AppHost/Properties/launchSettings.json new file mode 100644 index 000000000..db2fc2688 --- /dev/null +++ b/examples/stable-diffusion-cpp/CommunityToolkit.Aspire.Hosting.StableDiffusionCpp.AppHost/Properties/launchSettings.json @@ -0,0 +1,14 @@ +{ + "$schema": "https://json.schemastore.org/launchsettings.json", + "profiles": { + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development", + "DOTNET_ENVIRONMENT": "Development" + } + } + } +} diff --git a/src/CommunityToolkit.Aspire.Hosting.StableDiffusionCpp/CommunityToolkit.Aspire.Hosting.StableDiffusionCpp.csproj b/src/CommunityToolkit.Aspire.Hosting.StableDiffusionCpp/CommunityToolkit.Aspire.Hosting.StableDiffusionCpp.csproj new file mode 100644 index 000000000..4ad916751 --- /dev/null +++ b/src/CommunityToolkit.Aspire.Hosting.StableDiffusionCpp/CommunityToolkit.Aspire.Hosting.StableDiffusionCpp.csproj @@ -0,0 +1,17 @@ + + + + An Aspire hosting integration for the stable-diffusion.cpp image generation server. + hosting stable-diffusion ai image-generation huggingface + + + + + + + + + + + + diff --git a/src/CommunityToolkit.Aspire.Hosting.StableDiffusionCpp/HuggingFaceModelDownloader.cs b/src/CommunityToolkit.Aspire.Hosting.StableDiffusionCpp/HuggingFaceModelDownloader.cs new file mode 100644 index 000000000..f7f7012fa --- /dev/null +++ b/src/CommunityToolkit.Aspire.Hosting.StableDiffusionCpp/HuggingFaceModelDownloader.cs @@ -0,0 +1,196 @@ +using System.Collections.Concurrent; +using System.Net; +using System.Net.Http.Headers; +using Aspire.Hosting.ApplicationModel; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; + +namespace Aspire.Hosting; + +internal static class HuggingFaceModelDownloader +{ + private const long ProgressIntervalBytes = 256L * 1024 * 1024; + private static readonly ConcurrentDictionary DownloadLocks = []; + + public static async Task DownloadAsync( + BeforeResourceStartedEvent @event, + StableDiffusionCppModelResource model, + CancellationToken cancellationToken) + { + var modelsDirectory = model.Parent.ModelsDirectory; + var targetPath = GetTargetPath(modelsDirectory, model.FileName); + var partialPath = $"{targetPath}.part"; + var downloadLock = DownloadLocks.GetOrAdd(targetPath, _ => new SemaphoreSlim(1, 1)); + var logger = @event.Services + .GetRequiredService() + .GetLogger(model); + + await downloadLock.WaitAsync(cancellationToken).ConfigureAwait(false); + + try + { + Directory.CreateDirectory(modelsDirectory); + Directory.CreateDirectory(Path.Combine(modelsDirectory, "loras")); + Directory.CreateDirectory(Path.Combine(modelsDirectory, "upscalers")); + Directory.CreateDirectory(model.Parent.OutputDirectory); + + if (File.Exists(targetPath) && new FileInfo(targetPath).Length > 0) + { + logger.LogInformation("Hugging Face model already exists at {ModelPath}.", targetPath); + return; + } + + Directory.CreateDirectory(Path.GetDirectoryName(targetPath)!); + + var token = model.Parent.HuggingFaceToken is null + ? null + : await model.Parent.HuggingFaceToken.GetValueAsync(cancellationToken).ConfigureAwait(false); + + var downloadUri = BuildDownloadUri(model.Repository, model.Revision, model.FileName); + + for (var attempt = 1; attempt <= 5; attempt++) + { + try + { + await DownloadOnceAsync(downloadUri, partialPath, token, logger, cancellationToken) + .ConfigureAwait(false); + + File.Move(partialPath, targetPath, overwrite: true); + logger.LogInformation("Hugging Face model downloaded to {ModelPath}.", targetPath); + return; + } + catch (Exception exception) when ( + attempt < 5 && + exception is HttpRequestException or IOException) + { + var delay = TimeSpan.FromSeconds(Math.Pow(2, attempt)); + logger.LogWarning( + exception, + "Model download attempt {Attempt} failed. Retrying in {Delay}.", + attempt, + delay); + await Task.Delay(delay, cancellationToken).ConfigureAwait(false); + } + } + } + finally + { + downloadLock.Release(); + } + } + + private static async Task DownloadOnceAsync( + Uri downloadUri, + string partialPath, + string? token, + ILogger logger, + CancellationToken cancellationToken) + { + var existingLength = File.Exists(partialPath) ? new FileInfo(partialPath).Length : 0; + + using var handler = new HttpClientHandler { AutomaticDecompression = DecompressionMethods.All }; + using var client = new HttpClient(handler) { Timeout = Timeout.InfiniteTimeSpan }; + using var request = new HttpRequestMessage(HttpMethod.Get, downloadUri); + + if (!string.IsNullOrWhiteSpace(token)) + { + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token); + } + + if (existingLength > 0) + { + request.Headers.Range = new RangeHeaderValue(existingLength, null); + logger.LogInformation( + "Resuming Hugging Face model download from {DownloadedBytes} bytes: {DownloadUri}", + existingLength, + downloadUri); + } + else + { + logger.LogInformation("Downloading Hugging Face model: {DownloadUri}", downloadUri); + } + + using var response = await client.SendAsync( + request, + HttpCompletionOption.ResponseHeadersRead, + cancellationToken).ConfigureAwait(false); + + response.EnsureSuccessStatusCode(); + + var canAppend = existingLength > 0 && response.StatusCode == HttpStatusCode.PartialContent; + var fileMode = canAppend ? FileMode.Append : FileMode.Create; + var downloadedLength = canAppend ? existingLength : 0; + var nextProgress = downloadedLength + ProgressIntervalBytes; + + await using var source = await response.Content.ReadAsStreamAsync(cancellationToken) + .ConfigureAwait(false); + await using var destination = new FileStream( + partialPath, + fileMode, + FileAccess.Write, + FileShare.None, + bufferSize: 1024 * 1024, + useAsync: true); + + var buffer = new byte[1024 * 1024]; + int bytesRead; + + while ((bytesRead = await source.ReadAsync(buffer, cancellationToken).ConfigureAwait(false)) > 0) + { + await destination.WriteAsync(buffer.AsMemory(0, bytesRead), cancellationToken) + .ConfigureAwait(false); + downloadedLength += bytesRead; + + if (downloadedLength >= nextProgress) + { + logger.LogInformation( + "Downloaded {DownloadedMegabytes:N0} MiB.", + downloadedLength / 1024d / 1024d); + nextProgress += ProgressIntervalBytes; + } + } + + await destination.FlushAsync(cancellationToken).ConfigureAwait(false); + + if (response.Content.Headers.ContentLength is { } responseLength) + { + var expectedLength = (canAppend ? existingLength : 0) + responseLength; + if (downloadedLength != expectedLength) + { + throw new IOException( + $"Incomplete model download. Expected {expectedLength} bytes, received {downloadedLength} bytes."); + } + } + } + + internal static string GetTargetPath(string modelsDirectory, string fileName) + { + var rootPath = Path.GetFullPath(modelsDirectory); + var relativePath = fileName + .Replace('/', Path.DirectorySeparatorChar) + .Replace('\\', Path.DirectorySeparatorChar); + var targetPath = Path.GetFullPath(Path.Combine(rootPath, relativePath)); + + if (!targetPath.StartsWith( + rootPath + Path.DirectorySeparatorChar, + StringComparison.OrdinalIgnoreCase)) + { + throw new InvalidOperationException( + $"Model file path '{fileName}' escapes the models directory."); + } + + return targetPath; + } + + internal static Uri BuildDownloadUri(string repository, string revision, string fileName) + { + static string EscapePath(string value) => + string.Join( + '/', + value.Split('/', StringSplitOptions.RemoveEmptyEntries) + .Select(Uri.EscapeDataString)); + + return new Uri( + $"https://huggingface.co/{EscapePath(repository)}/resolve/{Uri.EscapeDataString(revision)}/{EscapePath(fileName)}"); + } +} diff --git a/src/CommunityToolkit.Aspire.Hosting.StableDiffusionCpp/README.md b/src/CommunityToolkit.Aspire.Hosting.StableDiffusionCpp/README.md new file mode 100644 index 000000000..6362e522b --- /dev/null +++ b/src/CommunityToolkit.Aspire.Hosting.StableDiffusionCpp/README.md @@ -0,0 +1,65 @@ +# CommunityToolkit.Aspire.Hosting.StableDiffusionCpp + +An Aspire hosting integration for the official +[`stable-diffusion.cpp`](https://github.com/leejet/stable-diffusion.cpp) server images. + +## Getting started + +Install the package in an Aspire AppHost: + +```dotnetcli +dotnet add package CommunityToolkit.Aspire.Hosting.StableDiffusionCpp +``` + +Add the server and a model hosted on Hugging Face: + +```csharp +var stableDiffusion = builder.AddStableDiffusionCpp( + "stable-diffusion", + StableDiffusionCppImageVariant.Cuda) + .WithGPUSupport() + .WithVaeTiling(); + +stableDiffusion.AddHuggingFaceModel( + "stabilityai/stable-diffusion-xl-base-1.0", + "sd_xl_base_1.0.safetensors"); +``` + +The model is downloaded before the container starts. Interrupted downloads resume from +the partial file, and subsequent starts reuse the cached file under +`.stable-diffusion-cpp/{resource-name}/models`. + +The integration uses official `ghcr.io/leejet/stable-diffusion.cpp` images. Available +variants are `Cuda`, `CudaSpark`, `Vulkan`, `Sycl`, and `Musa`. + +## Gated or private Hugging Face models + +Provide a secret parameter containing a Hugging Face access token: + +```csharp +var token = builder.AddParameter("hugging-face-token", secret: true); + +var stableDiffusion = builder + .AddStableDiffusionCpp("stable-diffusion") + .WithHuggingFaceToken(token); + +stableDiffusion.AddHuggingFaceModel( + "organization/model", + "model.safetensors"); +``` + +## LoRA and upscalers + +Place compatible LoRA files under: + +```text +.stable-diffusion-cpp/{resource-name}/models/loras +``` + +Place high-resolution upscaler models under: + +```text +.stable-diffusion-cpp/{resource-name}/models/upscalers +``` + +The Web UI and API are exposed through the resource's `http` endpoint. diff --git a/src/CommunityToolkit.Aspire.Hosting.StableDiffusionCpp/StableDiffusionCppContainerImageTags.cs b/src/CommunityToolkit.Aspire.Hosting.StableDiffusionCpp/StableDiffusionCppContainerImageTags.cs new file mode 100644 index 000000000..dc9ebdb04 --- /dev/null +++ b/src/CommunityToolkit.Aspire.Hosting.StableDiffusionCpp/StableDiffusionCppContainerImageTags.cs @@ -0,0 +1,12 @@ +namespace CommunityToolkit.Aspire.Hosting.StableDiffusionCpp; + +internal static class StableDiffusionCppContainerImageTags +{ + public const string Registry = "ghcr.io"; + public const string Image = "leejet/stable-diffusion.cpp"; + public const string CudaTag = "master-cuda"; + public const string CudaSparkTag = "master-cuda-spark"; + public const string VulkanTag = "master-vulkan"; + public const string SyclTag = "master-sycl"; + public const string MusaTag = "master-musa"; +} diff --git a/src/CommunityToolkit.Aspire.Hosting.StableDiffusionCpp/StableDiffusionCppImageVariant.cs b/src/CommunityToolkit.Aspire.Hosting.StableDiffusionCpp/StableDiffusionCppImageVariant.cs new file mode 100644 index 000000000..99d6cc97d --- /dev/null +++ b/src/CommunityToolkit.Aspire.Hosting.StableDiffusionCpp/StableDiffusionCppImageVariant.cs @@ -0,0 +1,22 @@ +namespace Aspire.Hosting; + +/// +/// Official stable-diffusion.cpp container image variants. +/// +public enum StableDiffusionCppImageVariant +{ + /// NVIDIA CUDA backend. + Cuda, + + /// NVIDIA CUDA backend optimized for DGX Spark. + CudaSpark, + + /// Vulkan backend. + Vulkan, + + /// Intel SYCL backend. + Sycl, + + /// Moore Threads MUSA backend. + Musa, +} diff --git a/src/CommunityToolkit.Aspire.Hosting.StableDiffusionCpp/StableDiffusionCppModelResource.cs b/src/CommunityToolkit.Aspire.Hosting.StableDiffusionCpp/StableDiffusionCppModelResource.cs new file mode 100644 index 000000000..fe9354b13 --- /dev/null +++ b/src/CommunityToolkit.Aspire.Hosting.StableDiffusionCpp/StableDiffusionCppModelResource.cs @@ -0,0 +1,61 @@ +using System.Diagnostics.CodeAnalysis; +using System.Runtime.CompilerServices; + +#pragma warning disable ASPIREATS001 // AspireExport is experimental + +namespace Aspire.Hosting.ApplicationModel; + +/// +/// Represents a model hosted by stable-diffusion.cpp and downloaded from Hugging Face. +/// +[AspireExport(ExposeProperties = true)] +public sealed class StableDiffusionCppModelResource( + string name, + string repository, + string fileName, + string revision, + StableDiffusionCppResource parent) + : Resource(name), + IResourceWithParent, + IResourceWithConnectionString +{ + /// + /// Gets the parent stable-diffusion.cpp server. + /// + public StableDiffusionCppResource Parent { get; } = ThrowIfNull(parent); + + /// + /// Gets the Hugging Face repository identifier. + /// + public string Repository { get; } = ThrowIfNull(repository); + + /// + /// Gets the model file path inside the Hugging Face repository. + /// + public string FileName { get; } = ThrowIfNull(fileName); + + /// + /// Gets the Hugging Face revision. + /// + public string Revision { get; } = ThrowIfNull(revision); + + /// + public ReferenceExpression ConnectionStringExpression => + ReferenceExpression.Create( + $"{Parent.ConnectionStringExpression};Model={Repository}/{FileName};Revision={Revision}"); + + IEnumerable> + IResourceWithConnectionString.GetConnectionProperties() + { + yield return new("Uri", Parent.UriExpression); + yield return new("Model", ReferenceExpression.Create($"{Repository}/{FileName}")); + yield return new("Revision", ReferenceExpression.Create($"{Revision}")); + } + + private static T ThrowIfNull( + [NotNull] T? argument, + [CallerArgumentExpression(nameof(argument))] string? parameterName = null) => + argument ?? throw new ArgumentNullException(parameterName); +} + +#pragma warning restore ASPIREATS001 // AspireExport is experimental diff --git a/src/CommunityToolkit.Aspire.Hosting.StableDiffusionCpp/StableDiffusionCppResource.cs b/src/CommunityToolkit.Aspire.Hosting.StableDiffusionCpp/StableDiffusionCppResource.cs new file mode 100644 index 000000000..701458e0b --- /dev/null +++ b/src/CommunityToolkit.Aspire.Hosting.StableDiffusionCpp/StableDiffusionCppResource.cs @@ -0,0 +1,80 @@ +#pragma warning disable ASPIREATS001 // AspireExport is experimental + +namespace Aspire.Hosting.ApplicationModel; + +/// +/// Represents a stable-diffusion.cpp HTTP server container. +/// +[AspireExport(ExposeProperties = true)] +public sealed class StableDiffusionCppResource( + string name, + StableDiffusionCppImageVariant imageVariant, + string modelsDirectory, + string outputDirectory) + : ContainerResource(name), IResourceWithConnectionString +{ + internal const string HttpEndpointName = "http"; + internal const int HttpTargetPort = 1234; + + private EndpointReference? _primaryEndpoint; + + /// + /// Gets the selected official container image variant. + /// + public StableDiffusionCppImageVariant ImageVariant { get; } = imageVariant; + + internal string ModelsDirectory { get; } = modelsDirectory; + + internal string OutputDirectory { get; } = outputDirectory; + + internal ParameterResource? HuggingFaceToken { get; private set; } + + /// + /// Gets the configured Hugging Face model. + /// + public StableDiffusionCppModelResource? Model { get; private set; } + + /// + /// Gets the primary HTTP endpoint. + /// + public EndpointReference PrimaryEndpoint => + _primaryEndpoint ??= new EndpointReference(this, HttpEndpointName); + + /// + /// Gets the HTTP server URI. + /// + public ReferenceExpression UriExpression => + ReferenceExpression.Create( + $"{PrimaryEndpoint.Property(EndpointProperty.Scheme)}://{PrimaryEndpoint.Property(EndpointProperty.Host)}:{PrimaryEndpoint.Property(EndpointProperty.Port)}"); + + /// + public ReferenceExpression ConnectionStringExpression => + ReferenceExpression.Create($"Endpoint={UriExpression}"); + + IEnumerable> + IResourceWithConnectionString.GetConnectionProperties() + { + yield return new("Uri", UriExpression); + } + + internal void SetModel(StableDiffusionCppModelResource model) + { + ArgumentNullException.ThrowIfNull(model); + + if (Model is not null) + { + throw new InvalidOperationException( + "stable-diffusion.cpp supports one active model per server resource."); + } + + Model = model; + } + + internal void SetHuggingFaceToken(ParameterResource token) + { + ArgumentNullException.ThrowIfNull(token); + HuggingFaceToken = token; + } +} + +#pragma warning restore ASPIREATS001 // AspireExport is experimental diff --git a/src/CommunityToolkit.Aspire.Hosting.StableDiffusionCpp/StableDiffusionCppResourceBuilderExtensions.cs b/src/CommunityToolkit.Aspire.Hosting.StableDiffusionCpp/StableDiffusionCppResourceBuilderExtensions.cs new file mode 100644 index 000000000..9d7138079 --- /dev/null +++ b/src/CommunityToolkit.Aspire.Hosting.StableDiffusionCpp/StableDiffusionCppResourceBuilderExtensions.cs @@ -0,0 +1,233 @@ +using Aspire.Hosting.ApplicationModel; +using CommunityToolkit.Aspire.Hosting.StableDiffusionCpp; + +#pragma warning disable ASPIREATS001 // AspireExport is experimental + +namespace Aspire.Hosting; + +/// +/// Provides extension methods for adding stable-diffusion.cpp resources to an +/// . +/// +public static class StableDiffusionCppResourceBuilderExtensions +{ + /// + /// Adds a stable-diffusion.cpp container resource using an official image. + /// + /// The distributed application builder. + /// The resource name. + /// The official image variant to use. + /// An optional host port. Aspire assigns one when omitted. + /// + /// An optional host directory used for models. Defaults to + /// .stable-diffusion-cpp/{name}/models under the AppHost directory. + /// + /// A builder for the stable-diffusion.cpp resource. + [AspireExport] + public static IResourceBuilder AddStableDiffusionCpp( + this IDistributedApplicationBuilder builder, + [ResourceName] string name, + StableDiffusionCppImageVariant imageVariant = StableDiffusionCppImageVariant.Cuda, + int? port = null, + string? modelsDirectory = null) + { + ArgumentNullException.ThrowIfNull(builder); + ArgumentException.ThrowIfNullOrWhiteSpace(name); + + modelsDirectory ??= Path.Combine( + builder.AppHostDirectory, + ".stable-diffusion-cpp", + name, + "models"); + modelsDirectory = Path.GetFullPath(modelsDirectory, builder.AppHostDirectory); + + var outputDirectory = Path.Combine( + builder.AppHostDirectory, + ".stable-diffusion-cpp", + name, + "output"); + + var resource = new StableDiffusionCppResource( + name, + imageVariant, + modelsDirectory, + outputDirectory); + + return builder.AddResource(resource) + .WithImage(StableDiffusionCppContainerImageTags.Image, GetImageTag(imageVariant)) + .WithImageRegistry(StableDiffusionCppContainerImageTags.Registry) + .WithEntrypoint("/sd-server") + .WithArgs( + "--listen-ip", "0.0.0.0", + "--listen-port", StableDiffusionCppResource.HttpTargetPort.ToString(), + "--lora-model-dir", "/models/loras", + "--hires-upscalers-dir", "/models/upscalers") + .WithBindMount(modelsDirectory, "/models") + .WithBindMount(outputDirectory, "/output") + .WithHttpEndpoint( + port: port, + targetPort: StableDiffusionCppResource.HttpTargetPort, + name: StableDiffusionCppResource.HttpEndpointName) + .WithUrlForEndpoint(StableDiffusionCppResource.HttpEndpointName, annotation => + { + annotation.DisplayText = "Web UI"; + }) + .WithHttpHealthCheck("/"); + } + + /// + /// Adds the container runtime arguments required by the selected GPU backend. + /// + /// The stable-diffusion.cpp resource builder. + /// The resource builder. + [AspireExport] + public static IResourceBuilder WithGPUSupport( + this IResourceBuilder builder) + { + ArgumentNullException.ThrowIfNull(builder); + + return builder.Resource.ImageVariant switch + { + StableDiffusionCppImageVariant.Cuda or + StableDiffusionCppImageVariant.CudaSpark => + builder.ApplicationBuilder.GetContainerRuntime() == "podman" + ? builder.WithContainerRuntimeArgs("--device", "nvidia.com/gpu=all") + : builder.WithContainerRuntimeArgs("--gpus", "all"), + + StableDiffusionCppImageVariant.Vulkan or + StableDiffusionCppImageVariant.Sycl => + builder.WithContainerRuntimeArgs("--device", "/dev/dri"), + + StableDiffusionCppImageVariant.Musa => builder, + _ => throw new ArgumentOutOfRangeException( + nameof(builder.Resource.ImageVariant), + builder.Resource.ImageVariant, + "Unsupported stable-diffusion.cpp image variant."), + }; + } + + /// + /// Processes VAE images in tiles to reduce peak memory usage during image decoding. + /// + /// The stable-diffusion.cpp resource builder. + /// The resource builder. + [AspireExport] + public static IResourceBuilder WithVaeTiling( + this IResourceBuilder builder) + { + ArgumentNullException.ThrowIfNull(builder); + return builder.WithArgs("--vae-tiling"); + } + + /// + /// Supplies a Hugging Face access token for gated or private repositories. + /// + /// The stable-diffusion.cpp resource builder. + /// A secret parameter containing the Hugging Face token. + /// The resource builder. + [AspireExport] + public static IResourceBuilder WithHuggingFaceToken( + this IResourceBuilder builder, + IResourceBuilder token) + { + ArgumentNullException.ThrowIfNull(builder); + ArgumentNullException.ThrowIfNull(token); + + builder.Resource.SetHuggingFaceToken(token.Resource); + return builder; + } + + /// + /// Adds a Hugging Face model that is downloaded before the container starts. + /// + /// The stable-diffusion.cpp resource builder. + /// The Hugging Face repository identifier. + /// The model file path within the repository. + /// The repository revision. Defaults to main. + /// A builder for the child model resource. + [AspireExport] + public static IResourceBuilder AddHuggingFaceModel( + this IResourceBuilder builder, + string repository, + string fileName, + string revision = "main") + { + ArgumentNullException.ThrowIfNull(builder); + ArgumentException.ThrowIfNullOrWhiteSpace(repository); + ArgumentException.ThrowIfNullOrWhiteSpace(fileName); + ArgumentException.ThrowIfNullOrWhiteSpace(revision); + + var modelName = Path.GetFileNameWithoutExtension(fileName) + .Replace('.', '-') + .Replace('_', '-'); + + return AddHuggingFaceModel( + builder, + $"{builder.Resource.Name}-{modelName}", + repository, + fileName, + revision); + } + + /// + /// Adds a named Hugging Face model that is downloaded before the container starts. + /// + /// The stable-diffusion.cpp resource builder. + /// The child model resource name. + /// The Hugging Face repository identifier. + /// The model file path within the repository. + /// The repository revision. Defaults to main. + /// A builder for the child model resource. + [AspireExport("addNamedHuggingFaceModel")] + public static IResourceBuilder AddHuggingFaceModel( + this IResourceBuilder builder, + [ResourceName] string name, + string repository, + string fileName, + string revision = "main") + { + ArgumentNullException.ThrowIfNull(builder); + ArgumentException.ThrowIfNullOrWhiteSpace(name); + ArgumentException.ThrowIfNullOrWhiteSpace(repository); + ArgumentException.ThrowIfNullOrWhiteSpace(fileName); + ArgumentException.ThrowIfNullOrWhiteSpace(revision); + + var model = new StableDiffusionCppModelResource( + name, + repository, + fileName, + revision, + builder.Resource); + + builder.Resource.SetModel(model); + builder.WithArgs("--model", $"/models/{fileName.Replace('\\', '/')}"); + + builder.ApplicationBuilder.Eventing.Subscribe( + builder.Resource, + (@event, cancellationToken) => + HuggingFaceModelDownloader.DownloadAsync(@event, model, cancellationToken)); + + return builder.ApplicationBuilder.AddResource(model) + .WithParentRelationship(builder.Resource); + } + + private static string GetImageTag(StableDiffusionCppImageVariant imageVariant) => + imageVariant switch + { + StableDiffusionCppImageVariant.Cuda => StableDiffusionCppContainerImageTags.CudaTag, + StableDiffusionCppImageVariant.CudaSpark => StableDiffusionCppContainerImageTags.CudaSparkTag, + StableDiffusionCppImageVariant.Vulkan => StableDiffusionCppContainerImageTags.VulkanTag, + StableDiffusionCppImageVariant.Sycl => StableDiffusionCppContainerImageTags.SyclTag, + StableDiffusionCppImageVariant.Musa => StableDiffusionCppContainerImageTags.MusaTag, + _ => throw new ArgumentOutOfRangeException( + nameof(imageVariant), + imageVariant, + "Unsupported stable-diffusion.cpp image variant."), + }; + + private static string? GetContainerRuntime(this IDistributedApplicationBuilder builder) => + (builder.Configuration["ASPIRE_CONTAINER_RUNTIME"] ?? + builder.Configuration["DOTNET_ASPIRE_CONTAINER_RUNTIME"])?.ToLowerInvariant(); +} + +#pragma warning restore ASPIREATS001 // AspireExport is experimental diff --git a/tests/CommunityToolkit.Aspire.Hosting.StableDiffusionCpp.Tests/AddStableDiffusionCppTests.cs b/tests/CommunityToolkit.Aspire.Hosting.StableDiffusionCpp.Tests/AddStableDiffusionCppTests.cs new file mode 100644 index 000000000..55853c3e5 --- /dev/null +++ b/tests/CommunityToolkit.Aspire.Hosting.StableDiffusionCpp.Tests/AddStableDiffusionCppTests.cs @@ -0,0 +1,206 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Aspire.Hosting; + +namespace CommunityToolkit.Aspire.Hosting.StableDiffusionCpp.Tests; + +public class AddStableDiffusionCppTests +{ + [Theory] + [InlineData(StableDiffusionCppImageVariant.Cuda, "master-cuda")] + [InlineData(StableDiffusionCppImageVariant.CudaSpark, "master-cuda-spark")] + [InlineData(StableDiffusionCppImageVariant.Vulkan, "master-vulkan")] + [InlineData(StableDiffusionCppImageVariant.Sycl, "master-sycl")] + [InlineData(StableDiffusionCppImageVariant.Musa, "master-musa")] + public void ResourceUsesSelectedOfficialImage( + StableDiffusionCppImageVariant variant, + string expectedTag) + { + var builder = DistributedApplication.CreateBuilder(); + + builder.AddStableDiffusionCpp("stable-diffusion", variant); + + using var app = builder.Build(); + var resource = GetResource(app); + var image = Assert.Single(resource.Annotations.OfType()); + + Assert.Equal("ghcr.io", image.Registry); + Assert.Equal("leejet/stable-diffusion.cpp", image.Image); + Assert.Equal(expectedTag, image.Tag); + Assert.Equal(variant, resource.ImageVariant); + } + + [Fact] + public void ResourceHasHttpEndpointAndHealthCheck() + { + var builder = DistributedApplication.CreateBuilder(); + + builder.AddStableDiffusionCpp("stable-diffusion", port: 5678); + + using var app = builder.Build(); + var resource = GetResource(app); + var endpoint = Assert.Single(resource.Annotations.OfType()); + + Assert.Equal("http", endpoint.Name); + Assert.Equal(5678, endpoint.Port); + Assert.Equal(1234, endpoint.TargetPort); + Assert.Single(resource.Annotations.OfType()); + } + + [Fact] + public void ResourceUsesServerEntrypointAndRequiredDirectories() + { + var builder = DistributedApplication.CreateBuilder(); + + builder.AddStableDiffusionCpp("stable-diffusion"); + + using var app = builder.Build(); + var resource = GetResource(app); + + var mounts = resource.Annotations.OfType().ToList(); + Assert.Contains(mounts, mount => mount.Target == "/models"); + Assert.Contains(mounts, mount => mount.Target == "/output"); + + var args = resource.Annotations.OfType().ToList(); + Assert.NotEmpty(args); + } + + [Fact] + public void HuggingFaceModelRegistersChildResource() + { + var builder = DistributedApplication.CreateBuilder(); + var server = builder.AddStableDiffusionCpp("stable-diffusion"); + + var modelBuilder = server.AddHuggingFaceModel( + "stabilityai/stable-diffusion-xl-base-1.0", + "sd_xl_base_1.0.safetensors", + "revision"); + + using var app = builder.Build(); + var model = Assert.Single( + app.Services.GetRequiredService() + .Resources.OfType()); + + Assert.Same(modelBuilder.Resource, model); + Assert.Same(server.Resource, model.Parent); + Assert.Same(model, server.Resource.Model); + Assert.Equal("stabilityai/stable-diffusion-xl-base-1.0", model.Repository); + Assert.Equal("sd_xl_base_1.0.safetensors", model.FileName); + Assert.Equal("revision", model.Revision); + Assert.Contains( + model.Annotations.OfType(), + annotation => annotation.Resource == server.Resource && annotation.Type == "Parent"); + } + + [Fact] + public void OnlyOneActiveModelCanBeAdded() + { + var builder = DistributedApplication.CreateBuilder(); + var server = builder.AddStableDiffusionCpp("stable-diffusion"); + server.AddHuggingFaceModel("organization/model", "first.safetensors"); + + var action = () => + server.AddHuggingFaceModel("organization/model", "second.safetensors"); + + Assert.Throws(action); + } + + [Fact] + public void HuggingFaceTokenIsStoredAsParameterResource() + { + var builder = DistributedApplication.CreateBuilder(); + var token = builder.AddParameter("hugging-face-token", secret: true); + var server = builder.AddStableDiffusionCpp("stable-diffusion") + .WithHuggingFaceToken(token); + + Assert.Same(token.Resource, server.Resource.HuggingFaceToken); + } + + [Theory] + [InlineData(StableDiffusionCppImageVariant.Cuda, "--gpus", "all")] + [InlineData(StableDiffusionCppImageVariant.CudaSpark, "--gpus", "all")] + [InlineData(StableDiffusionCppImageVariant.Vulkan, "--device", "/dev/dri")] + [InlineData(StableDiffusionCppImageVariant.Sycl, "--device", "/dev/dri")] + public async Task GpuSupportUsesVariantRuntimeArguments( + StableDiffusionCppImageVariant variant, + string firstArgument, + string secondArgument) + { + var builder = DistributedApplication.CreateBuilder(); + var resourceBuilder = builder.AddStableDiffusionCpp("stable-diffusion", variant) + .WithGPUSupport(); + + using var app = builder.Build(); + var annotation = Assert.Single( + resourceBuilder.Resource.Annotations.OfType()); + var context = new ContainerRuntimeArgsCallbackContext([]); + + await annotation.Callback(context); + + Assert.Equal([firstArgument, secondArgument], context.Args); + } + + [Fact] + public async Task NvidiaGpuSupportUsesPodmanDevice() + { + var builder = DistributedApplication.CreateBuilder(); + builder.Configuration["ASPIRE_CONTAINER_RUNTIME"] = "podman"; + var resourceBuilder = builder + .AddStableDiffusionCpp("stable-diffusion", StableDiffusionCppImageVariant.Cuda) + .WithGPUSupport(); + + using var app = builder.Build(); + var annotation = Assert.Single( + resourceBuilder.Resource.Annotations.OfType()); + var context = new ContainerRuntimeArgsCallbackContext([]); + + await annotation.Callback(context); + + Assert.Equal(["--device", "nvidia.com/gpu=all"], context.Args); + } + + [Fact] + public void DownloadUriEscapesRepositoryRevisionAndFilePath() + { + var uri = HuggingFaceModelDownloader.BuildDownloadUri( + "organization/model", + "feature branch", + "checkpoints/model file.safetensors"); + + Assert.Equal( + "https://huggingface.co/organization/model/resolve/feature%20branch/checkpoints/model%20file.safetensors", + uri.AbsoluteUri); + } + + [Fact] + public void TargetPathRejectsTraversal() + { + var root = Path.Combine(Path.GetTempPath(), "stable-diffusion-cpp-models"); + + var action = () => HuggingFaceModelDownloader.GetTargetPath(root, "../secret.txt"); + + Assert.Throws(action); + } + + [Fact] + public void PublicMethodsValidateArguments() + { + IDistributedApplicationBuilder nullApplicationBuilder = null!; + IResourceBuilder nullResourceBuilder = null!; + + Assert.Throws( + () => nullApplicationBuilder.AddStableDiffusionCpp("stable-diffusion")); + Assert.Throws( + () => nullResourceBuilder.WithGPUSupport()); + Assert.Throws( + () => nullResourceBuilder.WithVaeTiling()); + Assert.Throws( + () => nullResourceBuilder.AddHuggingFaceModel("organization/model", "model.safetensors")); + } + + private static StableDiffusionCppResource GetResource(DistributedApplication app) => + Assert.Single( + app.Services.GetRequiredService() + .Resources.OfType()); +} \ No newline at end of file diff --git a/tests/CommunityToolkit.Aspire.Hosting.StableDiffusionCpp.Tests/CommunityToolkit.Aspire.Hosting.StableDiffusionCpp.Tests.csproj b/tests/CommunityToolkit.Aspire.Hosting.StableDiffusionCpp.Tests/CommunityToolkit.Aspire.Hosting.StableDiffusionCpp.Tests.csproj new file mode 100644 index 000000000..1a71bba7c --- /dev/null +++ b/tests/CommunityToolkit.Aspire.Hosting.StableDiffusionCpp.Tests/CommunityToolkit.Aspire.Hosting.StableDiffusionCpp.Tests.csproj @@ -0,0 +1,7 @@ + + + + + + +