Skip to content
Open
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
5 changes: 5 additions & 0 deletions CommunityToolkit.Aspire.slnx
Original file line number Diff line number Diff line change
Expand Up @@ -214,6 +214,9 @@
<Project Path="examples/squad/CommunityToolkit.Aspire.Hosting.Squad.AppHost/CommunityToolkit.Aspire.Hosting.Squad.AppHost.csproj" />
<Project Path="examples/squad/CommunityToolkit.Aspire.Hosting.Squad.ServiceDefaults/CommunityToolkit.Aspire.Hosting.Squad.ServiceDefaults.csproj" />
</Folder>
<Folder Name="/examples/stable-diffusion-cpp/">
<Project Path="examples/stable-diffusion-cpp/CommunityToolkit.Aspire.Hosting.StableDiffusionCpp.AppHost/CommunityToolkit.Aspire.Hosting.StableDiffusionCpp.AppHost.csproj" />
</Folder>
<Folder Name="/examples/stripe/">
<Project Path="examples/stripe/CommunityToolkit.Aspire.Hosting.Stripe.Api/CommunityToolkit.Aspire.Hosting.Stripe.Api.csproj" />
<Project Path="examples/stripe/CommunityToolkit.Aspire.Hosting.Stripe.AppHost/CommunityToolkit.Aspire.Hosting.Stripe.AppHost.csproj" />
Expand Down Expand Up @@ -288,6 +291,7 @@
<Project Path="src/CommunityToolkit.Aspire.Hosting.Sqlite/CommunityToolkit.Aspire.Hosting.Sqlite.csproj" />
<Project Path="src/CommunityToolkit.Aspire.Hosting.SqlServer.Extensions/CommunityToolkit.Aspire.Hosting.SqlServer.Extensions.csproj" />
<Project Path="src/CommunityToolkit.Aspire.Hosting.Squad/CommunityToolkit.Aspire.Hosting.Squad.csproj" />
<Project Path="src/CommunityToolkit.Aspire.Hosting.StableDiffusionCpp/CommunityToolkit.Aspire.Hosting.StableDiffusionCpp.csproj" />
<Project Path="src/CommunityToolkit.Aspire.Hosting.Stripe/CommunityToolkit.Aspire.Hosting.Stripe.csproj" />
<Project Path="src/CommunityToolkit.Aspire.Hosting.SurrealDb/CommunityToolkit.Aspire.Hosting.SurrealDb.csproj" />
<Project Path="src/CommunityToolkit.Aspire.Hosting.Zitadel/CommunityToolkit.Aspire.Hosting.Zitadel.csproj" />
Expand Down Expand Up @@ -363,6 +367,7 @@
<Project Path="tests/CommunityToolkit.Aspire.Hosting.Sqlite.Tests/CommunityToolkit.Aspire.Hosting.Sqlite.Tests.csproj" />
<Project Path="tests/CommunityToolkit.Aspire.Hosting.SqlServer.Extensions.Tests/CommunityToolkit.Aspire.Hosting.SqlServer.Extensions.Tests.csproj" />
<Project Path="tests/CommunityToolkit.Aspire.Hosting.Squad.Tests/CommunityToolkit.Aspire.Hosting.Squad.Tests.csproj" />
<Project Path="tests/CommunityToolkit.Aspire.Hosting.StableDiffusionCpp.Tests/CommunityToolkit.Aspire.Hosting.StableDiffusionCpp.Tests.csproj" />
<Project Path="tests/CommunityToolkit.Aspire.Hosting.Stripe.Tests/CommunityToolkit.Aspire.Hosting.Stripe.Tests.csproj" />
<Project Path="tests/CommunityToolkit.Aspire.Hosting.SurrealDb.Tests/CommunityToolkit.Aspire.Hosting.SurrealDb.Tests.csproj" />
<Project Path="tests/CommunityToolkit.Aspire.Hosting.Zitadel.Tests/CommunityToolkit.Aspire.Hosting.Zitadel.Tests.csproj" />
Expand Down
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ This repository contains the source code for the Aspire Community Toolkit, a col
| - **Learn More**: [`Hosting.Kind`][kind-integration-docs] <br /> - Stable 📦: [![CommunityToolkit.Aspire.Hosting.Kind][kind-shields]][kind-nuget] <br /> - 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] <br /> - Stable 📦: [![CommunityToolkit.Aspire.NodeJS.Extensions][nodejs-ext-shields]][nodejs-ext-nuget] <br /> - 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] <br /> - Stable 📦: [![CommunityToolkit.Aspire.Hosting.Ollama][ollama-shields]][ollama-nuget] <br /> - 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) <br /> - 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] <br /> - Stable 📦: [![CommunityToolkit.Aspire.OllamaSharp][ollamasharp-shields]][ollamasharp-nuget] <br /> - 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] <br /> - Stable 📦: [![CommunityToolkit.Aspire.Hosting.Meilisearch][meilisearch-shields]][meilisearch-nuget] <br /> - 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] <br /> - Stable 📦: [![CommunityToolkit.Aspire.Meilisearch][meilisearch-client-shields]][meilisearch-client-nuget] <br /> - 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. |
Expand Down Expand Up @@ -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/
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
/.stable-diffusion-cpp/
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
<Project Sdk="Aspire.AppHost.Sdk/13.4.3">

<PropertyGroup>
<OutputType>Exe</OutputType>
<UserSecretsId>communitytoolkit-aspire-stable-diffusion-cpp</UserSecretsId>
</PropertyGroup>

<ItemGroup>
<ProjectReference Include="..\..\..\src\CommunityToolkit.Aspire.Hosting.StableDiffusionCpp\CommunityToolkit.Aspire.Hosting.StableDiffusionCpp.csproj" IsAspireProjectResource="false" />
</ItemGroup>

</Project>
Original file line number Diff line number Diff line change
@@ -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();
Original file line number Diff line number Diff line change
@@ -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"
}
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
<Project Sdk="Microsoft.NET.Sdk">

<PropertyGroup>
<Description>An Aspire hosting integration for the stable-diffusion.cpp image generation server.</Description>
<AdditionalPackageTags>hosting stable-diffusion ai image-generation huggingface</AdditionalPackageTags>
</PropertyGroup>

<ItemGroup>
<PackageReference Include="Aspire.Hosting" />
<PackageReference Include="MessagePack" />
</ItemGroup>

<ItemGroup>
<InternalsVisibleTo Include="CommunityToolkit.Aspire.Hosting.StableDiffusionCpp.Tests" />
</ItemGroup>

</Project>
Original file line number Diff line number Diff line change
@@ -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<string, SemaphoreSlim> 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<ResourceLoggerService>()
.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)}");
}
}
65 changes: 65 additions & 0 deletions src/CommunityToolkit.Aspire.Hosting.StableDiffusionCpp/README.md
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
@@ -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";
}
Loading
Loading