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 @@
+
+
+
+
+
+
+