diff --git a/.github/workflows/buildexe.yml b/.github/workflows/buildexe.yml new file mode 100644 index 000000000..819c3a283 --- /dev/null +++ b/.github/workflows/buildexe.yml @@ -0,0 +1,47 @@ +name: Build Stability Matrix (Windows) + +on: + push: + branches: [ "clean-main" ] + pull_request: + branches: [ "clean-main" ] + workflow_dispatch: + +jobs: + build-windows: + runs-on: windows-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Setup .NET SDK 9.x + uses: actions/setup-dotnet@v4 + with: + dotnet-version: '9.0.x' + + - name: Restore Avalonia project + run: dotnet restore StabilityMatrix.Avalonia/StabilityMatrix.Avalonia.csproj + + - name: Restore Tests project + run: dotnet restore StabilityMatrix.Tests/StabilityMatrix.Tests.csproj + + - name: Build Avalonia project + run: dotnet build StabilityMatrix.Avalonia/StabilityMatrix.Avalonia.csproj -c Release --no-restore --no-cache + + - name: Build Tests project + run: dotnet build StabilityMatrix.Tests/StabilityMatrix.Tests.csproj -c Release --no-restore + + - name: Run Tests + run: dotnet test StabilityMatrix.Tests/StabilityMatrix.Tests.csproj -c Release --no-build + + - name: Publish Avalonia app (win10-x64) + run: dotnet publish StabilityMatrix.Avalonia/StabilityMatrix.Avalonia.csproj -c Release -r win-x64 --self-contained true -p:PublishSingleFile=true -p:IncludeNativeLibrariesForSelfExtract=true -o ./publish/win10-x64 + + - name: Upload Artifact + uses: actions/upload-artifact@v4 + with: + name: StabilityMatrix-win10-x64 + path: ./publish/win10-x64/* + compression-level: 6 + retention-days: 14 diff --git a/StabilityMatrix.Avalonia/Controls/Inference/TiledVAECard.axaml b/StabilityMatrix.Avalonia/Controls/Inference/TiledVAECard.axaml index 9c47cbb3c..f5bc44cf8 100644 --- a/StabilityMatrix.Avalonia/Controls/Inference/TiledVAECard.axaml +++ b/StabilityMatrix.Avalonia/Controls/Inference/TiledVAECard.axaml @@ -5,15 +5,16 @@ xmlns:ui="clr-namespace:FluentAvalonia.UI.Controls;assembly=FluentAvalonia" xmlns:vmInference="clr-namespace:StabilityMatrix.Avalonia.ViewModels.Inference" x:DataType="vmInference:TiledVAECardViewModel"> + + - + + - + - + - + + IsChecked="{Binding UseCustomTemporalTiling}" /> - - - - - + + + + + + + + + + + + + + + + + + diff --git a/StabilityMatrix.Avalonia/ViewModels/Dialogs/UpdateViewModel.cs b/StabilityMatrix.Avalonia/ViewModels/Dialogs/UpdateViewModel.cs index 40131ee01..5635e75e3 100644 --- a/StabilityMatrix.Avalonia/ViewModels/Dialogs/UpdateViewModel.cs +++ b/StabilityMatrix.Avalonia/ViewModels/Dialogs/UpdateViewModel.cs @@ -162,6 +162,68 @@ await updateHelper.DownloadUpdate( ); } + // Handle Linux AppImage update + if (Compat.IsLinux && Environment.GetEnvironmentVariable("APPIMAGE") is { } appImage) + { + try + { + var updateScriptPath = UpdateHelper.UpdateFolder.JoinFile("update_script.sh").FullPath; + var newAppImage = UpdateHelper.ExecutablePath.FullPath; + + var scriptContent = """ +#!/bin/bash +set -e + +# Wait for the process to exit +while kill -0 "$PID" 2>/dev/null; do + sleep 0.5 +done + +# Move the new AppImage over the old one +mv -f "$NEW_APPIMAGE" "$OLD_APPIMAGE" +chmod +x "$OLD_APPIMAGE" + +# Launch the new AppImage detached +"$OLD_APPIMAGE" > /dev/null 2>&1 & +disown +"""; + await File.WriteAllTextAsync(updateScriptPath, scriptContent); + + File.SetUnixFileMode( + updateScriptPath, + UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute + ); + + var startInfo = new System.Diagnostics.ProcessStartInfo + { + FileName = "/usr/bin/env", + Arguments = $"bash \"{updateScriptPath}\"", + UseShellExecute = false, + CreateNoWindow = true, + }; + + startInfo.EnvironmentVariables["PID"] = Environment.ProcessId.ToString(); + startInfo.EnvironmentVariables["NEW_APPIMAGE"] = newAppImage; + startInfo.EnvironmentVariables["OLD_APPIMAGE"] = appImage; + + System.Diagnostics.Process.Start(startInfo); + + App.Shutdown(); + return; + } + catch (Exception ex) + { + logger.LogError(ex, "Failed to execute AppImage update script"); + + var dialog = DialogHelper.CreateMarkdownDialog( + "AppImage update script failed. \nCould not replace old AppImage with new version. Please check directory permissions. \nFalling back to standard update process. User intervention required: \nAfter program closes, \nplease move the new AppImage extracted in the '.StabilityMatrixUpdate' hidden directory to the old AppImage overwriting it. \n\nClose this dialog to continue with standard update process.", + Resources.Label_UnexpectedErrorOccurred + ); + + await dialog.ShowAsync(); + } + } + // Set current version for update messages settingsManager.Transaction( s => s.UpdatingFromVersion = Compat.AppVersion, diff --git a/StabilityMatrix.Avalonia/ViewModels/Inference/InferenceWanTextToVideoViewModel.cs b/StabilityMatrix.Avalonia/ViewModels/Inference/InferenceWanTextToVideoViewModel.cs index 4b2ac1815..e5ef2da79 100644 --- a/StabilityMatrix.Avalonia/ViewModels/Inference/InferenceWanTextToVideoViewModel.cs +++ b/StabilityMatrix.Avalonia/ViewModels/Inference/InferenceWanTextToVideoViewModel.cs @@ -70,8 +70,8 @@ RunningPackageService runningPackageService BatchSizeCardViewModel = vmFactory.Get(); - VideoOutputSettingsCardViewModel = vmFactory.Get( - vm => vm.Fps = 16.0d + VideoOutputSettingsCardViewModel = vmFactory.Get(vm => + vm.Fps = 16.0d ); StackCardViewModel = vmFactory.Get(); @@ -94,7 +94,7 @@ protected override void BuildPrompt(BuildPromptEventArgs args) builder.Connections.Seed = args.SeedOverride switch { { } seed => Convert.ToUInt64(seed), - _ => Convert.ToUInt64(SeedCardViewModel.Seed) + _ => Convert.ToUInt64(SeedCardViewModel.Seed), }; // Load models @@ -115,7 +115,6 @@ protected override void BuildPrompt(BuildPromptEventArgs args) SamplerCardViewModel.ApplyStep(args); - // Animated webp output VideoOutputSettingsCardViewModel.ApplyStep(args); } @@ -165,13 +164,13 @@ CancellationToken cancellationToken OutputNodeNames = buildPromptArgs.Builder.Connections.OutputNodeNames.ToArray(), Parameters = SaveStateToParameters(new GenerationParameters()) with { - Seed = Convert.ToUInt64(seed) + Seed = Convert.ToUInt64(seed), }, Project = inferenceProject, FilesToTransfer = buildPromptArgs.FilesToTransfer, BatchIndex = i, // Only clear output images on the first batch - ClearOutputImages = i == 0 + ClearOutputImages = i == 0, }; batchArgs.Add(generationArgs); diff --git a/StabilityMatrix.Avalonia/ViewModels/Inference/Modules/TiledVAEModule.cs b/StabilityMatrix.Avalonia/ViewModels/Inference/Modules/TiledVAEModule.cs index 3199a53fa..36db2f0f1 100644 --- a/StabilityMatrix.Avalonia/ViewModels/Inference/Modules/TiledVAEModule.cs +++ b/StabilityMatrix.Avalonia/ViewModels/Inference/Modules/TiledVAEModule.cs @@ -4,6 +4,7 @@ using StabilityMatrix.Avalonia.ViewModels.Base; using StabilityMatrix.Core.Attributes; using StabilityMatrix.Core.Models.Api.Comfy.Nodes; +using NLog; namespace StabilityMatrix.Avalonia.ViewModels.Inference.Modules; @@ -11,6 +12,8 @@ namespace StabilityMatrix.Avalonia.ViewModels.Inference.Modules; [RegisterTransient] public class TiledVAEModule : ModuleBase { + private static readonly Logger logger = LogManager.GetCurrentClassLogger(); + public TiledVAEModule(IServiceManager vmFactory) : base(vmFactory) { @@ -22,7 +25,6 @@ protected override void OnApplyStep(ModuleApplyStepEventArgs e) { var card = GetCard(); - // Register a pre-output action that replaces standard VAE decode with tiled decode e.PreOutputActions.Add(args => { var builder = args.Builder; @@ -34,8 +36,10 @@ protected override void OnApplyStep(ModuleApplyStepEventArgs e) var latent = builder.Connections.Primary.AsT0; var vae = builder.Connections.GetDefaultVAE(); - // Use tiled VAE decode instead of standard decode - var tiledDecode = builder.Nodes.AddTypedNode( + logger.Debug("TiledVAE: Injecting TiledVAEDecode"); + logger.Debug("UseCustomTemporalTiling value at runtime: {value}", card.UseCustomTemporalTiling); + + var node = builder.Nodes.AddTypedNode( new ComfyNodeBuilder.TiledVAEDecode { Name = builder.Nodes.GetUniqueName("TiledVAEDecode"), @@ -43,13 +47,15 @@ protected override void OnApplyStep(ModuleApplyStepEventArgs e) Vae = vae, TileSize = card.TileSize, Overlap = card.Overlap, - TemporalSize = card.TemporalSize, - TemporalOverlap = card.TemporalOverlap + + // Temporal tiling (WAN requires temporal tiling) + TemporalSize = card.UseCustomTemporalTiling ? card.TemporalSize : 64, + TemporalOverlap = card.UseCustomTemporalTiling ? card.TemporalOverlap : 8, } ); // Update primary connection to the decoded image - builder.Connections.Primary = tiledDecode.Output; + builder.Connections.Primary = node.Output; }); } } diff --git a/StabilityMatrix.Avalonia/ViewModels/Inference/TiledVAECardViewModel.cs b/StabilityMatrix.Avalonia/ViewModels/Inference/TiledVAECardViewModel.cs index ae2920778..a89ba141f 100644 --- a/StabilityMatrix.Avalonia/ViewModels/Inference/TiledVAECardViewModel.cs +++ b/StabilityMatrix.Avalonia/ViewModels/Inference/TiledVAECardViewModel.cs @@ -14,24 +14,32 @@ public partial class TiledVAECardViewModel : LoadableViewModelBase { public const string ModuleKey = "TiledVAE"; + // Spatial tile size (valid for Wan) [ObservableProperty] [NotifyDataErrorInfo] [Required] [Range(64, 4096)] private int tileSize = 512; + // Spatial overlap [ObservableProperty] [NotifyDataErrorInfo] [Required] [Range(0, 4096)] private int overlap = 64; + // Toggle: Use custom temporal tiling settings + [ObservableProperty] + private bool useCustomTemporalTiling = false; + + // Temporal tile size (must be >= 8) [ObservableProperty] [NotifyDataErrorInfo] [Required] [Range(8, 4096)] private int temporalSize = 64; + // Temporal overlap (must be >= 4) [ObservableProperty] [NotifyDataErrorInfo] [Required] diff --git a/StabilityMatrix.Avalonia/ViewModels/Settings/InferenceSettingsViewModel.cs b/StabilityMatrix.Avalonia/ViewModels/Settings/InferenceSettingsViewModel.cs index 8cc7e19a4..e54d1bace 100644 --- a/StabilityMatrix.Avalonia/ViewModels/Settings/InferenceSettingsViewModel.cs +++ b/StabilityMatrix.Avalonia/ViewModels/Settings/InferenceSettingsViewModel.cs @@ -191,7 +191,7 @@ ISettingsManager settingsManager settings => settings.InferenceDimensionStepChange, true ); - + FavoriteDimensions .ToObservableChangeSet() .Throttle(TimeSpan.FromMilliseconds(50)) diff --git a/StabilityMatrix.Core/Inference/ComfyClient.cs b/StabilityMatrix.Core/Inference/ComfyClient.cs index 197adde6e..dbca6a66f 100644 --- a/StabilityMatrix.Core/Inference/ComfyClient.cs +++ b/StabilityMatrix.Core/Inference/ComfyClient.cs @@ -29,16 +29,15 @@ public class ComfyClient : InferenceClientBase private readonly IComfyApi comfyApi; private bool isDisposed; - private readonly JsonSerializerOptions jsonSerializerOptions = - new() + private readonly JsonSerializerOptions jsonSerializerOptions = new() + { + PropertyNamingPolicy = JsonNamingPolicies.SnakeCaseLower, + Converters = { - PropertyNamingPolicy = JsonNamingPolicies.SnakeCaseLower, - Converters = - { - new NodeConnectionBaseJsonConverter(), - new OneOfJsonConverter() - } - }; + new NodeConnectionBaseJsonConverter(), + new OneOfJsonConverter(), + }, + }; // ReSharper disable once MemberCanBePrivate.Global public string ClientId { get; } = Guid.NewGuid().ToString(); @@ -111,20 +110,20 @@ public ComfyClient(IApiFactory apiFactory, Uri baseAddress) { Scheme = "ws", Path = "/ws", - Query = $"clientId={ClientId}" + Query = $"clientId={ClientId}", }.Uri; webSocketClient = new WebsocketClient(wsUri) { Name = nameof(ComfyClient), - ReconnectTimeout = TimeSpan.FromSeconds(30) + ReconnectTimeout = TimeSpan.FromSeconds(30), }; - webSocketClient.DisconnectionHappened.Subscribe( - info => Logger.Info("Websocket Disconnected, ({Type})", info.Type) + webSocketClient.DisconnectionHappened.Subscribe(info => + Logger.Info("Websocket Disconnected, ({Type})", info.Type) ); - webSocketClient.ReconnectionHappened.Subscribe( - info => Logger.Info("Websocket Reconnected, ({Type})", info.Type) + webSocketClient.ReconnectionHappened.Subscribe(info => + Logger.Info("Websocket Reconnected, ({Type})", info.Type) ); webSocketClient.MessageReceived.Subscribe(OnMessageReceived); @@ -287,7 +286,7 @@ private void HandleBinaryMessage(byte[] data) Array.Reverse(typeBytes); }*/ - PreviewImageReceived?.Invoke(this, new ComfyWebSocketImageData { ImageBytes = data[8..], }); + PreviewImageReceived?.Invoke(this, new ComfyWebSocketImageData { ImageBytes = data[8..] }); } public override async Task ConnectAsync(CancellationToken cancellationToken = default) @@ -332,6 +331,7 @@ public async Task QueuePromptAsync( ) { var request = new ComfyPromptRequest { ClientId = ClientId, Prompt = nodes }; + var result = await comfyApi.PostPrompt(request, cancellationToken).ConfigureAwait(false); // Add task to dictionary and set it as the current task diff --git a/StabilityMatrix.Core/Models/Api/Comfy/Nodes/ComfyNodeBuilder.cs b/StabilityMatrix.Core/Models/Api/Comfy/Nodes/ComfyNodeBuilder.cs index c164a6324..baf394808 100644 --- a/StabilityMatrix.Core/Models/Api/Comfy/Nodes/ComfyNodeBuilder.cs +++ b/StabilityMatrix.Core/Models/Api/Comfy/Nodes/ComfyNodeBuilder.cs @@ -67,16 +67,16 @@ public record TiledVAEDecode : ComfyTypedNodeBase { public required LatentNodeConnection Samples { get; init; } public required VAENodeConnection Vae { get; init; } - + [Range(64, 4096)] public int TileSize { get; init; } = 512; - + [Range(0, 4096)] public int Overlap { get; init; } = 64; - + [Range(8, 4096)] public int TemporalSize { get; init; } = 64; - + [Range(4, 4096)] public int TemporalOverlap { get; init; } = 8; } @@ -1097,19 +1097,48 @@ public record NRS : ComfyTypedNodeBase public required double Squash { get; set; } } - public ImageNodeConnection Lambda_LatentToImage(LatentNodeConnection latent, VAENodeConnection vae) + public ImageNodeConnection Lambda_LatentToImage( + LatentNodeConnection latent, + VAENodeConnection vae, + bool useTiledVAE = false, + int tileSize = 512, + int overlap = 64, + int temporalSize = 64, + int temporalOverlap = 8 + ) { - var name = GetUniqueName("VAEDecode"); - return Nodes - .AddTypedNode( - new VAEDecode - { - Name = name, - Samples = latent, - Vae = vae, - } - ) - .Output; + if (useTiledVAE) + { + var name = GetUniqueName("VAEDecodeTiled"); + return Nodes + .AddTypedNode( + new TiledVAEDecode + { + Name = name, + Samples = latent, + Vae = vae, + TileSize = tileSize, + Overlap = overlap, + TemporalSize = temporalSize, + TemporalOverlap = temporalOverlap, + } + ) + .Output; + } + else + { + var name = GetUniqueName("VAEDecode"); + return Nodes + .AddTypedNode( + new VAEDecode + { + Name = name, + Samples = latent, + Vae = vae, + } + ) + .Output; + } } public LatentNodeConnection Lambda_ImageToLatent(ImageNodeConnection pixels, VAENodeConnection vae) @@ -1519,11 +1548,33 @@ public ImageNodeConnection GetPrimaryAsImage() /// /// Get or convert latest primary connection to image /// - public ImageNodeConnection GetPrimaryAsImage(PrimaryNodeConnection primary, VAENodeConnection vae) + public ImageNodeConnection GetPrimaryAsImage( + PrimaryNodeConnection primary, + VAENodeConnection vae, + bool useTiledVAE = false, + int tileSize = 512, + int overlap = 64, + int temporalSize = 64, + int temporalOverlap = 8 + ) { - return primary.Match(latent => Lambda_LatentToImage(latent, vae), image => image); + return primary.Match( + latent => + Lambda_LatentToImage( + latent, + vae, + useTiledVAE, + tileSize, + overlap, + temporalSize, + temporalOverlap + ), + image => image + ); } + + /// /// Get or convert latest primary connection to image /// diff --git a/StabilityMatrix.Core/Models/Packages/AiToolkit.cs b/StabilityMatrix.Core/Models/Packages/AiToolkit.cs index 24009cef2..fc1e388e7 100644 --- a/StabilityMatrix.Core/Models/Packages/AiToolkit.cs +++ b/StabilityMatrix.Core/Models/Packages/AiToolkit.cs @@ -35,10 +35,7 @@ IPyInstallationManager pyInstallationManager public override string LicenseUrl => "https://github.com/ostris/ai-toolkit/blob/main/LICENSE"; public override string LaunchCommand => string.Empty; - public override Uri PreviewImageUri => - new( - "https://camo.githubusercontent.com/ea35b399e0d659f9f2ee09cbedb58e1a3ec7a0eab763e8ae8d11d076aad5be40/68747470733a2f2f6f73747269732e636f6d2f77702d636f6e74656e742f75706c6f6164732f323032352f30322f746f6f6c6b69742d75692e6a7067" - ); + public override Uri PreviewImageUri => new("https://cdn.lykos.ai/sm/packages/aitoolkit/preview.webp"); public override string OutputFolderName => "output"; public override IEnumerable AvailableTorchIndices => [TorchIndex.Cuda]; diff --git a/StabilityMatrix.Core/Models/Packages/ComfyUI.cs b/StabilityMatrix.Core/Models/Packages/ComfyUI.cs index 6af2b047f..e0fe67e6a 100644 --- a/StabilityMatrix.Core/Models/Packages/ComfyUI.cs +++ b/StabilityMatrix.Core/Models/Packages/ComfyUI.cs @@ -382,7 +382,7 @@ await StandardPipInstallProcessAsync( var indexUrl = gfxArch switch { "gfx1151" => "https://rocm.nightlies.amd.com/v2/gfx1151", - _ when gfxArch.StartsWith("gfx110") => "https://rocm.nightlies.amd.com/v2/gfx110X-dgpu", + _ when gfxArch.StartsWith("gfx110") => "https://rocm.nightlies.amd.com/v2/gfx110X-all", _ when gfxArch.StartsWith("gfx120") => "https://rocm.nightlies.amd.com/v2/gfx120X-all", _ => throw new ArgumentOutOfRangeException( nameof(gfxArch), @@ -870,6 +870,8 @@ private ImmutableDictionary GetEnvVars(ImmutableDictionary "Prerequisite install may require admin privileges and a reboot. " + "Visual Studio Build Tools for C++ Desktop Development will be installed automatically. " - + "AMD GPUs under the RX 6800 may require additional manual setup."; + + "AMD GPUs under the RX 6800 may require additional manual setup. "; public override string LaunchCommand => Path.Combine("zluda", "zluda.exe"); + + public override List LaunchOptions + { + get + { + var options = new List + { + new() + { + Name = "Cross Attention Method", + Type = LaunchOptionType.Bool, + InitialValue = "--use-quad-cross-attention", + Options = + [ + "--use-split-cross-attention", + "--use-quad-cross-attention", + "--use-pytorch-cross-attention", + "--use-sage-attention", + ], + }, + new() + { + Name = "Disable Async Offload", + Type = LaunchOptionType.Bool, + InitialValue = true, + Options = ["--disable-async-offload"], + }, + new() + { + Name = "Disable Pinned Memory", + Type = LaunchOptionType.Bool, + InitialValue = true, + Options = ["--disable-pinned-memory"], + }, + new() + { + Name = "Disable Smart Memory", + Type = LaunchOptionType.Bool, + InitialValue = false, + Options = ["--disable-smart-memory"], + }, + new() + { + Name = "Disable Model/Node Caching", + Type = LaunchOptionType.Bool, + InitialValue = false, + Options = ["--cache-none"], + }, + }; + + options.AddRange( + base.LaunchOptions.Where(x => x.Name != "Cross Attention Method") + ); + return options; + } + } + public override IEnumerable AvailableTorchIndices => [TorchIndex.Zluda]; public override TorchIndex GetRecommendedTorchVersion() => TorchIndex.Zluda; diff --git a/StabilityMatrix.Tests/Models/Packages/PackageLinkTests.cs b/StabilityMatrix.Tests/Models/Packages/PackageLinkTests.cs index c0b3113e4..6c21c92d2 100644 --- a/StabilityMatrix.Tests/Models/Packages/PackageLinkTests.cs +++ b/StabilityMatrix.Tests/Models/Packages/PackageLinkTests.cs @@ -25,7 +25,6 @@ public sealed class PackageLinkTests Backoff.DecorrelatedJitterBackoffV2(TimeSpan.FromMilliseconds(200), 3), onRetry: (outcome, timespan, retryAttempt, context) => { - // Log retry attempt if needed Console.WriteLine($"Retry attempt {retryAttempt}, waiting {timespan.TotalSeconds} seconds"); } ); @@ -36,19 +35,23 @@ public async Task TestPreviewImageUri(BasePackage package) { var imageUri = package.PreviewImageUri; - // If is GitHub Uri, use jsdelivr instead due to rate limiting + // If GitHub URL, use jsDelivr to avoid rate limiting imageUri = GitHubToJsDelivr(imageUri); - // Test http head is successful with retry policy var response = await RetryPolicy.ExecuteAsync(async () => await HttpClient.SendAsync(new HttpRequestMessage(HttpMethod.Head, imageUri)) ); + // 403 should fail — URL is invalid or blocked + Assert.AreNotEqual( + System.Net.HttpStatusCode.Forbidden, + response.StatusCode, + $"PreviewImageUri returned 403 Forbidden: {imageUri}" + ); + Assert.IsTrue( response.IsSuccessStatusCode, - "Failed to get PreviewImageUri at {0}: {1}", - imageUri, - response + $"Failed to get PreviewImageUri at {imageUri}: {response}" ); } @@ -63,34 +66,32 @@ public async Task TestLicenseUrl(BasePackage package) var licenseUri = new Uri(package.LicenseUrl); - // If is GitHub Uri, use jsdelivr instead due to rate limiting + // If GitHub URL, use jsDelivr to avoid rate limiting licenseUri = GitHubToJsDelivr(licenseUri); - // Test http head is successful with retry policy var response = await RetryPolicy.ExecuteAsync(async () => await HttpClient.SendAsync(new HttpRequestMessage(HttpMethod.Head, licenseUri)) ); - Assert.IsTrue( - response.IsSuccessStatusCode, - "Failed to get LicenseUrl at {0}: {1}", - licenseUri, - response - ); + Assert.IsTrue(response.IsSuccessStatusCode, $"Failed to get LicenseUrl at {licenseUri}: {response}"); } private static Uri GitHubToJsDelivr(Uri uri) { - // Like https://github.com/user/Repo/blob/main/LICENSE - // becomes: https://cdn.jsdelivr.net/gh/user/Repo@main/LICENSE - if (uri.Host.Equals("github.com", StringComparison.OrdinalIgnoreCase)) + // Example: + // https://github.com/user/Repo/blob/main/LICENSE + // becomes: + // https://cdn.jsdelivr.net/gh/user/Repo@main/LICENSE + + if (!uri.Host.Equals("github.com", StringComparison.OrdinalIgnoreCase)) + return uri; + + var segments = uri.AbsolutePath.Split('/', StringSplitOptions.RemoveEmptyEntries); + + if (segments is [var user, var repo, "blob", var branch, ..]) { - var segments = uri.AbsolutePath.Split('/', StringSplitOptions.RemoveEmptyEntries); - if (segments is [var user, var repo, "blob", var branch, ..]) - { - var path = string.Join("/", segments.Skip(4)); - return new Uri($"https://cdn.jsdelivr.net/gh/{user}/{repo}@{branch}/{path}"); - } + var path = string.Join("/", segments.Skip(4)); + return new Uri($"https://cdn.jsdelivr.net/gh/{user}/{repo}@{branch}/{path}"); } return uri;