diff --git a/.superpowers/sdd/2026-09-07-studio-presets-test-flight/progress.md b/.superpowers/sdd/2026-09-07-studio-presets-test-flight/progress.md
new file mode 100644
index 0000000..a637ad2
--- /dev/null
+++ b/.superpowers/sdd/2026-09-07-studio-presets-test-flight/progress.md
@@ -0,0 +1,9 @@
+# SDD ledger — plan: docs/superpowers/plans/2026-09-07-studio-presets-test-flight.md
+
+## Tasks
+- [x] Task 1: Core Models & StudioPresetService with Unit Tests (commit 1ac7764)
+- [x] Task 2: Hardware Fit Pre-Flight Estimation in CanIRunItService (commit 70d3636)
+- [x] Task 3: Reusable Avalonia UI Controls (commit 2fe72f9)
+- [x] Task 4: Integrate Studio Presets, Hardware Fit, & Stage Tracking into Studio ViewModels and UI (commit 4cb3bc3)
+- [x] Task 5: Centralized Presets Manager in Settings View (commit afa0d2c)
+- [x] Task 6: Verification, Version Bump, and Branch/PR Preparation (577 passed tests, lint clean, typecheck clean)
diff --git a/Endpoints/HealthEndpoints.cs b/Endpoints/HealthEndpoints.cs
index e16e514..7258e18 100644
--- a/Endpoints/HealthEndpoints.cs
+++ b/Endpoints/HealthEndpoints.cs
@@ -29,7 +29,7 @@ public static void MapHealthEndpoints(this WebApplication app)
StableDiffusion = forgeHealthy ? "Online" : "Offline",
ComfyUI = comfyHealthy ? "Online" : "Offline",
PreferredImageEngine = settings.PreferredImageEngine,
- Version = typeof(Program).Assembly.GetName().Version?.ToString(3) ?? "3.12.1"
+ Version = "3.13.0"
});
});
}
diff --git a/LocalLLMServerManager.Shared/Interfaces/IStudioPresetService.cs b/LocalLLMServerManager.Shared/Interfaces/IStudioPresetService.cs
new file mode 100644
index 0000000..f1f4298
--- /dev/null
+++ b/LocalLLMServerManager.Shared/Interfaces/IStudioPresetService.cs
@@ -0,0 +1,20 @@
+namespace LocalLLMServerManager.Shared.Interfaces;
+
+using System.Collections.Generic;
+using LocalLLMServerManager.Shared.Models;
+
+///
+/// Service interface for managing built-in and user-defined studio presets.
+///
+public interface IStudioPresetService
+{
+ IReadOnlyList GetPresets(StudioModality modality);
+ IReadOnlyList GetAllPresets();
+ StudioPreset? GetPresetById(string id);
+ void SavePreset(StudioPreset preset);
+ bool DeletePreset(string id);
+ StudioPreset? DuplicatePreset(string id);
+ string ExportJson();
+ bool ImportJson(string json);
+ void ResetToDefaults();
+}
diff --git a/LocalLLMServerManager.Shared/LocalLLMServerManager.Shared.csproj b/LocalLLMServerManager.Shared/LocalLLMServerManager.Shared.csproj
index 8d7db09..91ccaf6 100644
--- a/LocalLLMServerManager.Shared/LocalLLMServerManager.Shared.csproj
+++ b/LocalLLMServerManager.Shared/LocalLLMServerManager.Shared.csproj
@@ -4,9 +4,9 @@
net10.0
enable
enable
- 3.12.1
- 3.12.1.0
- 3.12.1.0
+ 3.13.0
+ 3.13.0.0
+ 3.13.0.0
true
diff --git a/LocalLLMServerManager.Shared/Models/AppSettings.cs b/LocalLLMServerManager.Shared/Models/AppSettings.cs
index f3119f3..7492156 100644
--- a/LocalLLMServerManager.Shared/Models/AppSettings.cs
+++ b/LocalLLMServerManager.Shared/Models/AppSettings.cs
@@ -1,5 +1,8 @@
namespace LocalLLMServerManager;
+using System.Collections.Generic;
+using LocalLLMServerManager.Shared.Models;
+
///
/// Application settings persisted to settings.json next to the executable.
///
@@ -22,6 +25,8 @@ public record AppSettings(
string AudioEngineUrl = "http://127.0.0.1:8880",
string PreferredAudioVoice = "af_heart",
string VideoModelsPath = "",
- string VideoOutputPath = ""
+ string VideoOutputPath = "",
+ List? CustomPresets = null
);
+
diff --git a/LocalLLMServerManager.Shared/Models/HardwareFitModels.cs b/LocalLLMServerManager.Shared/Models/HardwareFitModels.cs
index 7b801c4..1c6143a 100644
--- a/LocalLLMServerManager.Shared/Models/HardwareFitModels.cs
+++ b/LocalLLMServerManager.Shared/Models/HardwareFitModels.cs
@@ -158,3 +158,14 @@ public record TelemetryInfo(
double AvailableRamMb
);
+///
+/// Pre-flight hardware compatibility assessment for Studio generation jobs.
+///
+public record StudioHardwareFit(
+ QuickFitBadge FitBadge,
+ double EstimatedVramMb,
+ string StatusText,
+ string RecommendedPresetName,
+ bool RequiresLlmUnload
+);
+
diff --git a/LocalLLMServerManager.Shared/Models/StudioPresetModels.cs b/LocalLLMServerManager.Shared/Models/StudioPresetModels.cs
new file mode 100644
index 0000000..ca0a282
--- /dev/null
+++ b/LocalLLMServerManager.Shared/Models/StudioPresetModels.cs
@@ -0,0 +1,54 @@
+namespace LocalLLMServerManager.Shared.Models;
+
+using System;
+
+///
+/// Studio modality types supported for generation presets.
+///
+public enum StudioModality
+{
+ Image,
+ Video,
+ Audio
+}
+
+///
+/// A preset configuration for Image, Video, or Audio/TTS generation.
+///
+public record StudioPreset
+{
+ public string Id { get; init; } = Guid.NewGuid().ToString();
+ public string Name { get; init; } = "";
+ public string Description { get; init; } = "";
+ public StudioModality Modality { get; init; } = StudioModality.Image;
+ public string WorkflowOrEngine { get; init; } = "";
+ public int Width { get; init; } = 832;
+ public int Height { get; init; } = 480;
+ public int FrameCount { get; init; } = 48;
+ public int Fps { get; init; } = 16;
+ public int DurationSeconds { get; init; } = 3;
+ public string VoiceProfile { get; init; } = "";
+ public string SamplePrompt { get; init; } = "";
+ public string NegativePrompt { get; init; } = "";
+ public bool IsBuiltIn { get; init; } = false;
+
+ public bool IsCustom => !IsBuiltIn;
+
+ public string SummaryText => Modality switch
+ {
+ StudioModality.Video => $"{Width}x{Height} • {FrameCount} frames • {Fps} fps",
+ StudioModality.Image => $"{Width}x{Height}",
+ StudioModality.Audio => !string.IsNullOrWhiteSpace(VoiceProfile) ? $"{VoiceProfile} • {DurationSeconds}s" : $"{DurationSeconds}s audio",
+ _ => $"{Width}x{Height}"
+ };
+
+ public string BadgeText => IsBuiltIn ? "🔒 Built-in" : "✨ Custom";
+
+ public string ModalityText => Modality switch
+ {
+ StudioModality.Video => "🎬 Video",
+ StudioModality.Image => "🎨 Image",
+ StudioModality.Audio => "🎵 Audio",
+ _ => Modality.ToString()
+ };
+}
diff --git a/LocalLLMServerManager.Shared/Services/CanIRunItService.cs b/LocalLLMServerManager.Shared/Services/CanIRunItService.cs
index cad5d8e..30751d2 100644
--- a/LocalLLMServerManager.Shared/Services/CanIRunItService.cs
+++ b/LocalLLMServerManager.Shared/Services/CanIRunItService.cs
@@ -609,6 +609,96 @@ public QuickFitBadge EvaluateQuickFit(string modelName, long? fileSizeBytes, str
);
}
+ ///
+ public StudioHardwareFit EstimateStudioHardwareFit(StudioModality modality, int width, int height, int frameCount, string workflow, double freeVramMb, double totalVramMb)
+ {
+ string wf = (workflow ?? "").Trim().ToLowerInvariant();
+ double estimatedVramMb;
+
+ switch (modality)
+ {
+ case StudioModality.Audio:
+ if (wf.Contains("stable") || wf.Contains("music") || wf.Contains("song") || wf.Contains("yue") || wf.Contains("audiocraft"))
+ {
+ estimatedVramMb = 2500.0;
+ }
+ else
+ {
+ // Kokoro or default TTS / speech
+ estimatedVramMb = 1500.0;
+ }
+ break;
+
+ case StudioModality.Image:
+ int imgW = width > 0 ? width : 1024;
+ int imgH = height > 0 ? height : 1024;
+ double imgPixels = (double)imgW * imgH;
+ double baseImagePixels = 1024.0 * 1024.0;
+ estimatedVramMb = Math.Round(4000.0 * (imgPixels / baseImagePixels));
+ break;
+
+ case StudioModality.Video:
+ default:
+ int vidW = width > 0 ? width : 832;
+ int vidH = height > 0 ? height : 480;
+ int vidFrames = frameCount > 0 ? frameCount : 48;
+ double vidPixels = (double)vidW * vidH;
+ // Baseline ~6000 MB for 480p (832x480, 48 frames), ~10000 MB for 720p (1280x720, 48 frames)
+ double baseDitMb = 3000.0;
+ double frameContextScaling = 3000.0 * (vidPixels / (832.0 * 480.0)) * (vidFrames / 48.0);
+ estimatedVramMb = Math.Round(baseDitMb + frameContextScaling);
+ break;
+ }
+
+ QuickFitBadge fitBadge;
+ string statusText;
+ string recommendedPreset = "";
+ bool requiresLlmUnload;
+
+ if (estimatedVramMb <= freeVramMb)
+ {
+ fitBadge = new QuickFitBadge(
+ BadgeText: "🟢 Ready",
+ BadgeColorHex: "#10B981",
+ Tooltip: $"Requires ~{estimatedVramMb:N0} MB VRAM. Fits comfortably in free GPU memory ({freeVramMb:N0} MB free).",
+ FitVerdict: FitVerdict.FullVram
+ );
+ statusText = "Ready";
+ requiresLlmUnload = false;
+ }
+ else if (estimatedVramMb <= totalVramMb)
+ {
+ fitBadge = new QuickFitBadge(
+ BadgeText: "🟡 Tight Fit",
+ BadgeColorHex: "#F59E0B",
+ Tooltip: $"Requires ~{estimatedVramMb:N0} MB VRAM. Exceeds free VRAM ({freeVramMb:N0} MB) but fits in total VRAM ({totalVramMb:N0} MB). Active LLM will be unloaded.",
+ FitVerdict: FitVerdict.PartialOffload
+ );
+ statusText = "Tight Fit";
+ requiresLlmUnload = true;
+ }
+ else
+ {
+ fitBadge = new QuickFitBadge(
+ BadgeText: "🔴 Exceeds GPU Limit",
+ BadgeColorHex: "#EF4444",
+ Tooltip: $"Requires ~{estimatedVramMb:N0} MB VRAM which exceeds total GPU capacity ({totalVramMb:N0} MB). Consider switching to a lower resolution preset.",
+ FitVerdict: FitVerdict.OutOfMemory
+ );
+ statusText = "Exceeds GPU Limit";
+ recommendedPreset = "Quick 480p Preview";
+ requiresLlmUnload = true;
+ }
+
+ return new StudioHardwareFit(
+ FitBadge: fitBadge,
+ EstimatedVramMb: estimatedVramMb,
+ StatusText: statusText,
+ RecommendedPresetName: recommendedPreset,
+ RequiresLlmUnload: requiresLlmUnload
+ );
+ }
+
private static double ExtractParamBillions(string modelName)
{
if (string.IsNullOrWhiteSpace(modelName))
diff --git a/LocalLLMServerManager.Shared/Services/ICanIRunItService.cs b/LocalLLMServerManager.Shared/Services/ICanIRunItService.cs
index b8ad36e..92a94f2 100644
--- a/LocalLLMServerManager.Shared/Services/ICanIRunItService.cs
+++ b/LocalLLMServerManager.Shared/Services/ICanIRunItService.cs
@@ -37,4 +37,11 @@ public interface ICanIRunItService
/// Generates a lightweight compatibility badge suitable for search/library cards.
///
QuickFitBadge EvaluateQuickFit(string modelName, long? fileSizeBytes, string modality, long vramMb, long ramMb);
+
+ ///
+ /// Evaluates pre-flight hardware fit for studio generation workloads (Image, Video, Audio)
+ /// based on modality, resolution, frame counts, workflow, and current VRAM telemetry.
+ ///
+ StudioHardwareFit EstimateStudioHardwareFit(StudioModality modality, int width, int height, int frameCount, string workflow, double freeVramMb, double totalVramMb);
}
+
diff --git a/LocalLLMServerManager.Shared/Services/StudioPresetService.cs b/LocalLLMServerManager.Shared/Services/StudioPresetService.cs
new file mode 100644
index 0000000..934547f
--- /dev/null
+++ b/LocalLLMServerManager.Shared/Services/StudioPresetService.cs
@@ -0,0 +1,339 @@
+namespace LocalLLMServerManager.Shared.Services;
+
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Text.Json;
+using LocalLLMServerManager.Shared.Interfaces;
+using LocalLLMServerManager.Shared.Models;
+
+///
+/// Service managing built-in and user-customized generation presets.
+///
+public class StudioPresetService : IStudioPresetService
+{
+ private readonly object _lock = new();
+ private readonly List _customPresets = new();
+
+ private static readonly List BuiltInPresets = new()
+ {
+ // Video presets
+ new StudioPreset
+ {
+ Id = "builtin-video-480p",
+ Name = "Quick 480p Preview",
+ Description = "Fast 480p preview optimized for rapid iteration and testing.",
+ Modality = StudioModality.Video,
+ WorkflowOrEngine = "wan2.2",
+ Width = 832,
+ Height = 480,
+ FrameCount = 48,
+ Fps = 16,
+ DurationSeconds = 3,
+ SamplePrompt = "A sleek sports car cruising down a neon-lit cyberpunk highway at night, cinematic lighting, 4k",
+ NegativePrompt = "blurry, low quality, distorted, watermark",
+ IsBuiltIn = true
+ },
+ new StudioPreset
+ {
+ Id = "builtin-video-720p",
+ Name = "Cinematic HD 720p",
+ Description = "High-definition 720p widescreen with fluid 24fps motion.",
+ Modality = StudioModality.Video,
+ WorkflowOrEngine = "wan2.2",
+ Width = 1280,
+ Height = 720,
+ FrameCount = 80,
+ Fps = 24,
+ DurationSeconds = 4,
+ SamplePrompt = "Cinematic drone shot of misty mountain peaks at sunrise, golden hour, volumetric rays, high detail",
+ NegativePrompt = "blurry, low resolution, artifacts, jitter",
+ IsBuiltIn = true
+ },
+ new StudioPreset
+ {
+ Id = "builtin-video-vertical-reel",
+ Name = "Vertical Reel 9:16",
+ Description = "Vertical portrait format ideal for mobile feeds and social reels.",
+ Modality = StudioModality.Video,
+ WorkflowOrEngine = "wan2.2",
+ Width = 480,
+ Height = 832,
+ FrameCount = 48,
+ Fps = 16,
+ DurationSeconds = 3,
+ SamplePrompt = "A stylish dancer in streetwear performing in an urban subway station, dynamic camera movement",
+ NegativePrompt = "static, blurry, cropped, watermark",
+ IsBuiltIn = true
+ },
+ new StudioPreset
+ {
+ Id = "builtin-video-master",
+ Name = "High-Fidelity Master",
+ Description = "Maximum quality setting with extended frames and smooth framerate.",
+ Modality = StudioModality.Video,
+ WorkflowOrEngine = "wan2.2",
+ Width = 1280,
+ Height = 720,
+ FrameCount = 96,
+ Fps = 24,
+ DurationSeconds = 4,
+ SamplePrompt = "Macro close-up of a blooming mechanical flower opening its bioluminescent petals in dark forest",
+ NegativePrompt = "deformed, stutter, blurry, low quality",
+ IsBuiltIn = true
+ },
+
+ // Image presets
+ new StudioPreset
+ {
+ Id = "builtin-image-square",
+ Name = "Standard Square 1024x1024",
+ Description = "Classic 1:1 balanced square format for general imagery and icons.",
+ Modality = StudioModality.Image,
+ WorkflowOrEngine = "comfy",
+ Width = 1024,
+ Height = 1024,
+ SamplePrompt = "An intricate clockwork dragon perched on an antique book, detailed brass gears, macro lens",
+ NegativePrompt = "blurry, mutated, extra limbs, watermark",
+ IsBuiltIn = true
+ },
+ new StudioPreset
+ {
+ Id = "builtin-image-landscape",
+ Name = "Landscape Wallpaper 1344x768",
+ Description = "16:9 widescreen format perfect for desktop wallpapers and landscape art.",
+ Modality = StudioModality.Image,
+ WorkflowOrEngine = "comfy",
+ Width = 1344,
+ Height = 768,
+ SamplePrompt = "Breathtaking landscape of a fantasy floating island archipelago with waterfalls flowing into the clouds, sunset",
+ NegativePrompt = "blurry, low resolution, ugly, artifacts",
+ IsBuiltIn = true
+ },
+ new StudioPreset
+ {
+ Id = "builtin-image-portrait",
+ Name = "Portrait Photo 768x1152",
+ Description = "Tall 2:3 portrait aspect ratio tailored for character portraits and fashion photography.",
+ Modality = StudioModality.Image,
+ WorkflowOrEngine = "comfy",
+ Width = 768,
+ Height = 1152,
+ SamplePrompt = "Studio portrait of an elven archer in ornamental silver armor, dramatic Rembrandt lighting, 85mm portrait photography",
+ NegativePrompt = "blurry, deformed eyes, extra fingers, poor lighting",
+ IsBuiltIn = true
+ },
+
+ // Audio presets
+ new StudioPreset
+ {
+ Id = "builtin-audio-storyteller",
+ Name = "Natural Storyteller",
+ Description = "Warm, natural voice profile tuned for narration, audiobooks, and long-form storytelling.",
+ Modality = StudioModality.Audio,
+ WorkflowOrEngine = "kokoro",
+ VoiceProfile = "af_heart",
+ DurationSeconds = 10,
+ SamplePrompt = "Welcome to the enchanted forest. Deep within these woods lies an ancient secret waiting to be discovered.",
+ IsBuiltIn = true
+ },
+ new StudioPreset
+ {
+ Id = "builtin-audio-broadcaster",
+ Name = "Energetic Broadcaster",
+ Description = "Punchy, dynamic male voice ideal for announcements, podcasts, and energetic intros.",
+ Modality = StudioModality.Audio,
+ WorkflowOrEngine = "kokoro",
+ VoiceProfile = "am_adam",
+ DurationSeconds = 5,
+ SamplePrompt = "Breaking news! The next-generation local AI engine is now live and running at peak performance.",
+ IsBuiltIn = true
+ },
+ new StudioPreset
+ {
+ Id = "builtin-audio-ambient",
+ Name = "Ambient Soundscape",
+ Description = "Atmospheric ambient generation for background themes and immersive audio environments.",
+ Modality = StudioModality.Audio,
+ WorkflowOrEngine = "kokoro",
+ DurationSeconds = 15,
+ SamplePrompt = "Gentle ocean waves crashing against a rocky shore at twilight with distant seagulls calling.",
+ IsBuiltIn = true
+ },
+ new StudioPreset
+ {
+ Id = "builtin-audio-song",
+ Name = "Full Song Generator",
+ Description = "Extended audio generation for musical motifs, melodic tracks, and synthesized songs.",
+ Modality = StudioModality.Audio,
+ WorkflowOrEngine = "kokoro",
+ DurationSeconds = 30,
+ SamplePrompt = "An uplifting synthwave track with driving 80s drum beat, arpeggiated bassline, and warm analog synthesizers.",
+ IsBuiltIn = true
+ }
+ };
+
+ public StudioPresetService(IEnumerable? customPresets = null)
+ {
+ if (customPresets != null)
+ {
+ foreach (var preset in customPresets)
+ {
+ if (!preset.IsBuiltIn)
+ {
+ _customPresets.Add(preset);
+ }
+ }
+ }
+ }
+
+ public IReadOnlyList GetPresets(StudioModality modality)
+ {
+ lock (_lock)
+ {
+ var builtIns = BuiltInPresets.Where(p => p.Modality == modality);
+ var customs = _customPresets.Where(p => p.Modality == modality);
+ return builtIns.Concat(customs).ToList();
+ }
+ }
+
+ public IReadOnlyList GetAllPresets()
+ {
+ lock (_lock)
+ {
+ return BuiltInPresets.Concat(_customPresets).ToList();
+ }
+ }
+
+ public StudioPreset? GetPresetById(string id)
+ {
+ if (string.IsNullOrWhiteSpace(id)) return null;
+
+ lock (_lock)
+ {
+ return _customPresets.FirstOrDefault(p => p.Id == id)
+ ?? BuiltInPresets.FirstOrDefault(p => p.Id == id);
+ }
+ }
+
+ public void SavePreset(StudioPreset preset)
+ {
+ if (preset == null) throw new ArgumentNullException(nameof(preset));
+
+ lock (_lock)
+ {
+ var targetPreset = preset with { IsBuiltIn = false };
+
+ // If an existing custom preset has the same Id, replace it
+ var index = _customPresets.FindIndex(p => p.Id == targetPreset.Id);
+ if (index >= 0)
+ {
+ _customPresets[index] = targetPreset;
+ }
+ else
+ {
+ // If it matched a built-in ID, assign a new unique ID
+ if (BuiltInPresets.Any(p => p.Id == targetPreset.Id))
+ {
+ targetPreset = targetPreset with { Id = Guid.NewGuid().ToString() };
+ }
+ _customPresets.Add(targetPreset);
+ }
+ }
+ }
+
+ public bool DeletePreset(string id)
+ {
+ if (string.IsNullOrWhiteSpace(id)) return false;
+
+ lock (_lock)
+ {
+ // Built-ins cannot be deleted
+ if (BuiltInPresets.Any(p => p.Id == id))
+ {
+ return false;
+ }
+
+ var removedCount = _customPresets.RemoveAll(p => p.Id == id);
+ return removedCount > 0;
+ }
+ }
+
+ public StudioPreset? DuplicatePreset(string id)
+ {
+ var existing = GetPresetById(id);
+ if (existing == null) return null;
+
+ var duplicate = existing with
+ {
+ Id = Guid.NewGuid().ToString(),
+ Name = $"{existing.Name} (Copy)",
+ IsBuiltIn = false
+ };
+
+ lock (_lock)
+ {
+ _customPresets.Add(duplicate);
+ }
+
+ return duplicate;
+ }
+
+ public string ExportJson()
+ {
+ lock (_lock)
+ {
+ return JsonSerializer.Serialize(_customPresets, new JsonSerializerOptions
+ {
+ WriteIndented = true,
+ PropertyNameCaseInsensitive = true
+ });
+ }
+ }
+
+ public bool ImportJson(string json)
+ {
+ if (string.IsNullOrWhiteSpace(json)) return false;
+
+ try
+ {
+ var imported = JsonSerializer.Deserialize>(json, new JsonSerializerOptions
+ {
+ PropertyNameCaseInsensitive = true
+ });
+
+ if (imported == null) return false;
+
+ lock (_lock)
+ {
+ foreach (var item in imported)
+ {
+ var customItem = item with { IsBuiltIn = false };
+ var index = _customPresets.FindIndex(p => p.Id == customItem.Id);
+ if (index >= 0)
+ {
+ _customPresets[index] = customItem;
+ }
+ else
+ {
+ _customPresets.Add(customItem);
+ }
+ }
+ }
+
+ return true;
+ }
+ catch
+ {
+ return false;
+ }
+ }
+
+ public void ResetToDefaults()
+ {
+ lock (_lock)
+ {
+ _customPresets.Clear();
+ }
+ }
+}
diff --git a/LocalLLMServerManager.Shared/ViewModels/AudioStudioViewModel.cs b/LocalLLMServerManager.Shared/ViewModels/AudioStudioViewModel.cs
index 49db3cd..7ba2efd 100644
--- a/LocalLLMServerManager.Shared/ViewModels/AudioStudioViewModel.cs
+++ b/LocalLLMServerManager.Shared/ViewModels/AudioStudioViewModel.cs
@@ -1,10 +1,13 @@
using System;
using System.Collections.ObjectModel;
+using System.Linq;
using System.Net.Http;
using System.Net.Http.Json;
using System.Threading.Tasks;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
+using LocalLLMServerManager.Shared.Interfaces;
+using LocalLLMServerManager.Shared.Models;
using LocalLLMServerManager.Shared.Services;
namespace LocalLLMServerManager.Shared.ViewModels;
@@ -27,6 +30,34 @@ DateTime CreatedAt
public partial class AudioStudioViewModel : ObservableObject
{
+ private readonly IStudioPresetService _presetService;
+ private readonly ICanIRunItService _canIRunItService;
+
+ public ObservableCollection AudioPresets { get; } = new();
+ public ObservableCollection AudioStarterPrompts { get; } = new();
+
+ [ObservableProperty] private StudioPreset? _selectedAudioPreset;
+ [ObservableProperty] private StudioHardwareFit? _audioHardwareFit;
+ [ObservableProperty] private string _voiceProfile = "af_heart";
+
+ // Rich 4-Stage Stepper & Live Log Tracking
+ [ObservableProperty] private int _generationStage = 0;
+ [ObservableProperty] private string _generationStageTitle = "Idle";
+ [ObservableProperty] private string _generationStageSubtext = "Ready to generate audio";
+ [ObservableProperty] private string _generationElapsedText = "⏱️ 0:00s elapsed";
+ [ObservableProperty] private string _elapsedTimerText = "⏱️ 0:00s elapsed";
+ [ObservableProperty] private string _liveLogOutput = "";
+ [ObservableProperty] private string _logsText = "";
+ [ObservableProperty] private bool _isLiveLogsExpanded = false;
+ [ObservableProperty] private bool _isLogsExpanded = false;
+ [ObservableProperty] private string _stage1Status = "Pending";
+ [ObservableProperty] private string _stage2Status = "Pending";
+ [ObservableProperty] private string _stage3Status = "Pending";
+ [ObservableProperty] private string _stage4Status = "Pending";
+ [ObservableProperty] private double _progressValue = 0.0;
+ [ObservableProperty] private bool _isIndeterminate = false;
+ [ObservableProperty] private string _hardwareStatusBadgeText = "🟢 Ready";
+
[ObservableProperty] private ObservableCollection _workflows = new();
[ObservableProperty] private AudioWorkflowItem? _selectedWorkflow;
[ObservableProperty] private string _prompt = "Cyberpunk atmospheric ambient drone, heavy synthesizer, cinematic low end, 48kHz stereo";
@@ -49,10 +80,18 @@ partial void OnIsPlayingChanged(bool value)
[ObservableProperty] private string _apiBase = OperatingSystem.IsBrowser() ? "" : "http://127.0.0.1:5246";
- public AudioStudioViewModel()
+ public AudioStudioViewModel() : this(null, null)
{
}
+ public AudioStudioViewModel(IStudioPresetService? presetService, ICanIRunItService? canIRunItService = null)
+ {
+ _presetService = presetService ?? new StudioPresetService();
+ _canIRunItService = canIRunItService ?? new CanIRunItService();
+ LoadAudioPresets();
+ RecalculateHardwareFit(8000, 16000);
+ }
+
public async Task LoadAudioWorkflowsAsync(string apiBase, HttpClient http)
{
try
@@ -108,12 +147,147 @@ public async Task LoadAudioFilesAsync(string apiBase, HttpClient http)
}
}
+ public void LoadAudioPresets()
+ {
+ AudioPresets.Clear();
+ AudioStarterPrompts.Clear();
+ foreach (var p in _presetService.GetPresets(StudioModality.Audio))
+ {
+ AudioPresets.Add(p);
+ AudioStarterPrompts.Add(p);
+ }
+ if (SelectedAudioPreset == null && AudioPresets.Count > 0)
+ {
+ SelectedAudioPreset = AudioPresets[0];
+ }
+ }
+
+ [RelayCommand]
+ public void SelectAudioPreset(StudioPreset? preset)
+ {
+ if (preset == null) return;
+ SelectedAudioPreset = preset;
+ if (!string.IsNullOrWhiteSpace(preset.SamplePrompt)) Prompt = preset.SamplePrompt;
+ if (!string.IsNullOrWhiteSpace(preset.NegativePrompt)) NegativePrompt = preset.NegativePrompt;
+ if (!string.IsNullOrWhiteSpace(preset.VoiceProfile)) VoiceProfile = preset.VoiceProfile;
+ if (preset.DurationSeconds > 0) DurationSeconds = preset.DurationSeconds;
+ RecalculateHardwareFit(8000, 16000);
+ }
+
+ [RelayCommand]
+ public void ApplyStarterChip(object? param)
+ {
+ if (param is StudioPreset p)
+ {
+ SelectAudioPreset(p);
+ }
+ else if (param is string name)
+ {
+ var matched = AudioPresets.FirstOrDefault(x => x.Name.Equals(name, StringComparison.OrdinalIgnoreCase));
+ if (matched != null) SelectAudioPreset(matched);
+ }
+ }
+
+ [RelayCommand]
+ public void SaveCurrentAsAudioPreset(object? customNameOrPreset = null)
+ {
+ string? name = customNameOrPreset is string s ? s : (customNameOrPreset is StudioPreset p ? p.Name : null);
+ var preset = new StudioPreset
+ {
+ Id = Guid.NewGuid().ToString(),
+ Name = string.IsNullOrWhiteSpace(name) ? $"Custom Audio Preset {AudioPresets.Count + 1}" : name,
+ Description = "User customized audio/TTS preset",
+ Modality = StudioModality.Audio,
+ WorkflowOrEngine = SelectedWorkflow?.Id ?? "kokoro",
+ VoiceProfile = VoiceProfile,
+ DurationSeconds = DurationSeconds,
+ SamplePrompt = Prompt,
+ NegativePrompt = NegativePrompt,
+ IsBuiltIn = false
+ };
+ _presetService.SavePreset(preset);
+ LoadAudioPresets();
+ SelectedAudioPreset = AudioPresets.FirstOrDefault(p => p.Id == preset.Id);
+ }
+
+ [RelayCommand]
+ public void DeleteCurrentAudioPreset(object? presetParam = null)
+ {
+ var preset = presetParam as StudioPreset ?? SelectedAudioPreset;
+ if (preset != null && !preset.IsBuiltIn)
+ {
+ _presetService.DeletePreset(preset.Id);
+ LoadAudioPresets();
+ }
+ }
+
+ [RelayCommand]
+ public void DuplicateCurrentAudioPreset(object? presetParam = null)
+ {
+ var preset = presetParam as StudioPreset ?? SelectedAudioPreset;
+ if (preset != null)
+ {
+ var dup = _presetService.DuplicatePreset(preset.Id);
+ LoadAudioPresets();
+ if (dup != null)
+ {
+ SelectedAudioPreset = AudioPresets.FirstOrDefault(p => p.Id == dup.Id);
+ }
+ }
+ }
+
+ [RelayCommand]
+ public void ToggleLiveLogs()
+ {
+ IsLiveLogsExpanded = !IsLiveLogsExpanded;
+ IsLogsExpanded = IsLiveLogsExpanded;
+ }
+
+ [RelayCommand]
+ public void CancelGeneration()
+ {
+ IsGenerating = false;
+ GenerationStage = 0;
+ GenerationStageTitle = "Cancelled";
+ GenerationStageSubtext = "Audio generation was cancelled.";
+ Stage1Status = "Pending";
+ Stage2Status = "Pending";
+ Stage3Status = "Pending";
+ Stage4Status = "Pending";
+ ProgressValue = 0;
+ StatusMessage = "Generation cancelled.";
+ }
+
+ public void RecalculateHardwareFit(double freeVramMb, double totalVramMb)
+ {
+ AudioHardwareFit = _canIRunItService.EstimateStudioHardwareFit(
+ StudioModality.Audio,
+ 0,
+ 0,
+ 0,
+ SelectedWorkflow?.Name ?? "kokoro",
+ freeVramMb,
+ totalVramMb
+ );
+ HardwareStatusBadgeText = AudioHardwareFit.FitBadge.BadgeText;
+ }
+
[RelayCommand]
public async Task GenerateAudioAsync(ParamContext? ctx = null)
{
if (IsGenerating) return;
IsGenerating = true;
+ GenerationStage = 1;
+ GenerationStageTitle = "1. VRAM & Model Prep";
+ GenerationStageSubtext = "Allocating memory and preparing audio synthesis pipeline...";
+ Stage1Status = "Active";
+ Stage2Status = "Pending";
+ Stage3Status = "Pending";
+ Stage4Status = "Pending";
+ ProgressValue = 15;
+ LiveLogOutput = $"[Stage 1] Initializing audio model {SelectedWorkflow?.Name ?? "kokoro"}...\n";
+ LogsText = LiveLogOutput;
StatusMessage = "Queuing audio workflow on ComfyUI...";
try
@@ -130,20 +304,53 @@ public async Task GenerateAudioAsync(ParamContext? ctx = null)
seed = Seed
};
+ Stage1Status = "Complete";
+ GenerationStage = 2;
+ GenerationStageTitle = "2. Denoising & Synthesis";
+ GenerationStageSubtext = "Synthesizing audio spectrogram / waveform latents...";
+ Stage2Status = "Active";
+ ProgressValue = 50;
+ LiveLogOutput += "[Stage 2] Generating audio latents with conditioning...\n";
+ LogsText = LiveLogOutput;
+
var response = await http.PostAsJsonAsync($"{apiBase}/api/audio/generate", payload);
if (response.IsSuccessStatusCode)
{
+ Stage2Status = "Complete";
+ GenerationStage = 3;
+ GenerationStageTitle = "3. Encoding & Assembly";
+ GenerationStageSubtext = "Encoding output WAV / MP3 track...";
+ Stage3Status = "Active";
+ ProgressValue = 85;
+ LiveLogOutput += "[Stage 3] Assembling audio stream...\n";
+ LogsText = LiveLogOutput;
+
StatusMessage = "🎵 Audio workflow queued successfully! Rendering track...";
await LoadAudioFilesAsync(apiBase, http);
+
+ Stage3Status = "Complete";
+ GenerationStage = 4;
+ GenerationStageTitle = "4. Ready";
+ GenerationStageSubtext = "Audio track ready for playback.";
+ Stage4Status = "Complete";
+ ProgressValue = 100;
+ LiveLogOutput += "[Stage 4] Track rendered successfully!\n";
+ LogsText = LiveLogOutput;
}
else
{
StatusMessage = "⚠️ Failed to queue audio generation.";
+ GenerationStage = 0;
+ GenerationStageTitle = "Error";
+ GenerationStageSubtext = "Failed to queue audio generation.";
}
}
catch (Exception ex)
{
StatusMessage = $"⚠️ Error: {ex.Message}";
+ GenerationStage = 0;
+ GenerationStageTitle = "Error";
+ GenerationStageSubtext = ex.Message;
}
finally
{
diff --git a/LocalLLMServerManager.Shared/ViewModels/MainViewModel.cs b/LocalLLMServerManager.Shared/ViewModels/MainViewModel.cs
index 11f4574..04aa2cf 100644
--- a/LocalLLMServerManager.Shared/ViewModels/MainViewModel.cs
+++ b/LocalLLMServerManager.Shared/ViewModels/MainViewModel.cs
@@ -78,6 +78,9 @@ public HttpClient Http
public ObservableCollection Toasts => ToastService.Instance.ActiveToasts;
+ public IStudioPresetService PresetService { get; }
+ private readonly ICanIRunItService _canIRunItService;
+
// Sub-ViewModels for modular feature breakdown
public TelemetryViewModel Telemetry { get; }
public OllamaLibraryViewModel Ollama { get; }
@@ -91,14 +94,14 @@ public HttpClient Http
private int _selectedTabIndex = 0;
[ObservableProperty]
- private string _appVersionText = $"LocalLLMServerManager v{typeof(MainViewModel).Assembly.GetName().Version?.ToString(3) ?? "3.12.1"} — Unified WASM & Desktop UI";
+ private string _appVersionText = $"LocalLLMServerManager v{typeof(MainViewModel).Assembly.GetName().Version?.ToString(3) ?? "3.13.0"} — Unified WASM & Desktop UI";
public MainViewModel() : this(null)
{
}
public MainViewModel(HttpClient? httpClient)
- : this(httpClient, new TelemetryService(), new OllamaModelService(), new HuggingFaceSearchService(), new CivitaiSearchService())
+ : this(httpClient, new TelemetryService(), new OllamaModelService(), new HuggingFaceSearchService(), new CivitaiSearchService(), new StudioPresetService(), new CanIRunItService())
{
}
@@ -107,7 +110,9 @@ public MainViewModel(
ITelemetryService telemetryService,
IOllamaModelService ollamaModelService,
IHuggingFaceSearchService hfSearchService,
- ICivitaiSearchService civitaiSearchService)
+ ICivitaiSearchService civitaiSearchService,
+ IStudioPresetService? studioPresetService = null,
+ ICanIRunItService? canIRunItService = null)
{
if (httpClient != null) _customHttp = httpClient;
@@ -124,26 +129,31 @@ public MainViewModel(
ApiBase = GetDefaultApiBase();
}
- var canIRunItService = new CanIRunItService();
+ _canIRunItService = canIRunItService ?? new CanIRunItService();
+ PresetService = studioPresetService ?? new StudioPresetService();
Telemetry = new TelemetryViewModel(telemetryService) { ApiBase = ApiBase };
- HardwareFit = new CanIRunItViewModel(canIRunItService, telemetryService, httpClient) { ApiBase = ApiBase };
- Ollama = new OllamaLibraryViewModel(ollamaModelService, canIRunItService, telemetryService)
+ HardwareFit = new CanIRunItViewModel(_canIRunItService, telemetryService, httpClient) { ApiBase = ApiBase };
+ Ollama = new OllamaLibraryViewModel(ollamaModelService, _canIRunItService, telemetryService)
{
ApiBase = ApiBase,
OnInspectModelRequested = (modelName, modality) => NavigateToCanIRunIt(modelName, modality)
};
- HuggingFace = new HuggingFaceSearchViewModel(hfSearchService, canIRunItService, telemetryService)
+ HuggingFace = new HuggingFaceSearchViewModel(hfSearchService, _canIRunItService, telemetryService)
{
ApiBase = ApiBase,
OnInspectModelRequested = (modelName, modality) => NavigateToCanIRunIt(modelName, modality)
};
- Civitai = new CivitaiSearchViewModel(civitaiSearchService, canIRunItService, telemetryService)
+ Civitai = new CivitaiSearchViewModel(civitaiSearchService, _canIRunItService, telemetryService)
{
ApiBase = ApiBase,
OnInspectModelRequested = (modelName, modality) => NavigateToCanIRunIt(modelName, modality)
};
- Settings = new SettingsViewModel { ApiBase = ApiBase };
- Audio = new AudioStudioViewModel { ApiBase = ApiBase };
+ Settings = new SettingsViewModel(PresetService) { ApiBase = ApiBase };
+ Audio = new AudioStudioViewModel(PresetService, _canIRunItService) { ApiBase = ApiBase };
+
+ LoadStudioPresets();
+ RecalculateVideoHardwareFit();
+ RecalculateImageHardwareFit();
_ = RefreshStatusAsync();
_ = Ollama.LoadInstalledModelsAsync(ApiBase, Http);
@@ -214,13 +224,150 @@ private static string GetDefaultApiBase()
public string SelectedTheme { get => Settings.SelectedTheme; set => Settings.SelectedTheme = value; }
public System.Collections.Generic.IReadOnlyList AvailableThemes => Settings.AvailableThemes;
+ // Studio Preset Collections & Selections
+ public ObservableCollection VideoPresets { get; } = new();
+ public ObservableCollection VideoStarterPrompts { get; } = new();
+ public ObservableCollection ImagePresets { get; } = new();
+ public ObservableCollection ImageStarterPrompts { get; } = new();
+ public ObservableCollection TestFlightStarterPrompts { get; } = new();
+
+ [ObservableProperty]
+ private StudioPreset? _selectedVideoPreset;
+
+ [ObservableProperty]
+ private StudioPreset? _selectedImagePreset;
+
+ [ObservableProperty]
+ private StudioHardwareFit? _videoHardwareFit;
+
+ [ObservableProperty]
+ private StudioHardwareFit? _imageHardwareFit;
+
+ // Image Studio Observable Properties
+ [ObservableProperty]
+ private string _imagePrompt = "An intricate clockwork dragon perched on an antique book, detailed brass gears, macro lens";
+
+ [ObservableProperty]
+ private string _imageNegativePrompt = "blurry, mutated, extra limbs, watermark";
+
+ [ObservableProperty]
+ private string _selectedImageWorkflow = "SDXL Base";
+
+ [ObservableProperty]
+ private string _imageResolution = "1024x1024";
+
+ [ObservableProperty]
+ private int _imageWidth = 1024;
+
+ [ObservableProperty]
+ private int _imageHeight = 1024;
+
+ [ObservableProperty]
+ private long _imageSeed = 42890;
+
+ [ObservableProperty]
+ private bool _isGeneratingImage;
+
+ [ObservableProperty]
+ private double _imageGenerationProgress;
+
+ // 4-Stage Stepper & Live Log Tracking
+ [ObservableProperty]
+ private int _generationStage = 0; // 0 = Idle, 1 = VRAM/Prep, 2 = Denoising, 3 = Encoding, 4 = Ready
+
+ [ObservableProperty]
+ private string _generationStageTitle = "Idle";
+
+ [ObservableProperty]
+ private string _generationStageSubtext = "Ready to generate";
+
+ [ObservableProperty]
+ private string _generationElapsedText = "⏱️ 0:00s elapsed";
+
+ [ObservableProperty]
+ private string _elapsedTimerText = "⏱️ 0:00s elapsed";
+
+ [ObservableProperty]
+ private string _liveLogOutput = "";
+
+ [ObservableProperty]
+ private string _logsText = "";
+
+ [ObservableProperty]
+ private bool _isLiveLogsExpanded = false;
+
+ [ObservableProperty]
+ private bool _isLogsExpanded = false;
+
+ [ObservableProperty]
+ private string _stage1Status = "Pending";
+
+ [ObservableProperty]
+ private string _stage2Status = "Pending";
+
+ [ObservableProperty]
+ private string _stage3Status = "Pending";
+
+ [ObservableProperty]
+ private string _stage4Status = "Pending";
+
+ [ObservableProperty]
+ private string _hardwareStatusBadgeText = "🟢 Ready";
+
+ // Test Flight Modal Properties
+ [ObservableProperty]
+ private bool _isTestFlightOpen = false;
+
+ [ObservableProperty]
+ private StudioModality _testFlightModality = StudioModality.Video;
+
+ [ObservableProperty]
+ private StudioPreset? _selectedTestFlightStarterPrompt;
+
+ [ObservableProperty]
+ private string _testFlightEngineStatusText = "✓ ComfyUI Online";
+
+ [ObservableProperty]
+ private bool _isTestFlightEngineOnline = true;
+
+ [ObservableProperty]
+ private string _testFlightVramStatusText = "✓ VRAM Clearance: Ready";
+
+ [ObservableProperty]
+ private bool _isTestFlightVramClear = true;
+
+ [ObservableProperty]
+ private bool _isTestFlightRunning = false;
+
+ [ObservableProperty]
+ private double _testFlightProgress = 0.0;
+
+ [ObservableProperty]
+ private bool _isTestFlightIndeterminate = false;
+
+ [ObservableProperty]
+ private string _testFlightStatusMessage = "Ready to launch";
+
+ [ObservableProperty]
+ private bool _isTestFlightSuccess = false;
+
+ [ObservableProperty]
+ private bool _testFlightHasError = false;
+
+ [ObservableProperty]
+ private string _testFlightErrorMessage = "";
+
+ [ObservableProperty]
+ private string _testFlightResultBannerText = "🎉 Test Flight Succeeded! Your local engine and GPU are verified and ready for generation.";
+
// Studio & Video Studio Observable Properties
[ObservableProperty]
private string _selectedStudioMode = "Video"; // "Images", "3D Mesh", "Video", "Audio"
[RelayCommand]
- public void SelectStudioMode(string mode)
+ public void SelectStudioMode(object? modeParam)
{
+ var mode = modeParam?.ToString();
if (!string.IsNullOrWhiteSpace(mode))
{
SelectedStudioMode = mode;
@@ -274,6 +421,355 @@ public void SelectStudioMode(string mode)
public ObservableCollection GeneratedVideosList { get; } = new();
+ public void LoadStudioPresets()
+ {
+ VideoPresets.Clear();
+ VideoStarterPrompts.Clear();
+ foreach (var p in PresetService.GetPresets(StudioModality.Video))
+ {
+ VideoPresets.Add(p);
+ VideoStarterPrompts.Add(p);
+ }
+ if (SelectedVideoPreset == null && VideoPresets.Count > 0)
+ {
+ SelectedVideoPreset = VideoPresets[0];
+ }
+
+ ImagePresets.Clear();
+ ImageStarterPrompts.Clear();
+ foreach (var p in PresetService.GetPresets(StudioModality.Image))
+ {
+ ImagePresets.Add(p);
+ ImageStarterPrompts.Add(p);
+ }
+ if (SelectedImagePreset == null && ImagePresets.Count > 0)
+ {
+ SelectedImagePreset = ImagePresets[0];
+ }
+
+ UpdateTestFlightState();
+ }
+
+ [RelayCommand]
+ public void SelectVideoPreset(StudioPreset? preset)
+ {
+ if (preset == null) return;
+ SelectedVideoPreset = preset;
+ VideoResolution = $"{preset.Width}x{preset.Height}";
+ VideoFrameCount = preset.FrameCount;
+ if (!string.IsNullOrWhiteSpace(preset.WorkflowOrEngine)) SelectedVideoWorkflow = preset.WorkflowOrEngine;
+ if (!string.IsNullOrWhiteSpace(preset.SamplePrompt)) VideoPrompt = preset.SamplePrompt;
+ if (!string.IsNullOrWhiteSpace(preset.NegativePrompt)) VideoNegativePrompt = preset.NegativePrompt;
+ RecalculateVideoHardwareFit();
+ }
+
+ [RelayCommand]
+ public void SelectImagePreset(StudioPreset? preset)
+ {
+ if (preset == null) return;
+ SelectedImagePreset = preset;
+ ImageWidth = preset.Width;
+ ImageHeight = preset.Height;
+ ImageResolution = $"{preset.Width}x{preset.Height}";
+ if (!string.IsNullOrWhiteSpace(preset.WorkflowOrEngine)) SelectedImageWorkflow = preset.WorkflowOrEngine;
+ if (!string.IsNullOrWhiteSpace(preset.SamplePrompt)) ImagePrompt = preset.SamplePrompt;
+ if (!string.IsNullOrWhiteSpace(preset.NegativePrompt)) ImageNegativePrompt = preset.NegativePrompt;
+ RecalculateImageHardwareFit();
+ }
+
+ [RelayCommand]
+ public void ApplyStarterChip(object? parameter)
+ {
+ if (parameter is StudioPreset preset)
+ {
+ if (preset.Modality == StudioModality.Video)
+ {
+ SelectVideoPreset(preset);
+ }
+ else if (preset.Modality == StudioModality.Image)
+ {
+ SelectImagePreset(preset);
+ }
+ else if (preset.Modality == StudioModality.Audio)
+ {
+ Audio.SelectAudioPreset(preset);
+ }
+ }
+ else if (parameter is string name)
+ {
+ var vMatch = VideoPresets.FirstOrDefault(p => p.Name.Equals(name, StringComparison.OrdinalIgnoreCase));
+ if (vMatch != null)
+ {
+ SelectVideoPreset(vMatch);
+ return;
+ }
+ var iMatch = ImagePresets.FirstOrDefault(p => p.Name.Equals(name, StringComparison.OrdinalIgnoreCase));
+ if (iMatch != null)
+ {
+ SelectImagePreset(iMatch);
+ return;
+ }
+ var aMatch = Audio.AudioPresets.FirstOrDefault(p => p.Name.Equals(name, StringComparison.OrdinalIgnoreCase));
+ if (aMatch != null)
+ {
+ Audio.SelectAudioPreset(aMatch);
+ }
+ }
+ }
+
+ [RelayCommand]
+ public void SaveCurrentAsVideoPreset(object? customNameOrPreset = null)
+ {
+ string? customName = customNameOrPreset is string s ? s : (customNameOrPreset is StudioPreset p ? p.Name : null);
+ var (w, h) = ParseResolution(VideoResolution, 832, 480);
+ var preset = new StudioPreset
+ {
+ Id = Guid.NewGuid().ToString(),
+ Name = string.IsNullOrWhiteSpace(customName) ? $"Custom Video Preset {VideoPresets.Count + 1}" : customName,
+ Description = "User customized video preset",
+ Modality = StudioModality.Video,
+ WorkflowOrEngine = SelectedVideoWorkflow,
+ Width = w,
+ Height = h,
+ FrameCount = VideoFrameCount,
+ Fps = 16,
+ DurationSeconds = (int)Math.Max(1, Math.Round((double)VideoFrameCount / 16.0)),
+ SamplePrompt = VideoPrompt,
+ NegativePrompt = VideoNegativePrompt,
+ IsBuiltIn = false
+ };
+ PresetService.SavePreset(preset);
+ LoadStudioPresets();
+ SelectedVideoPreset = VideoPresets.FirstOrDefault(x => x.Id == preset.Id);
+ }
+
+ [RelayCommand]
+ public void DeleteCurrentVideoPreset(object? presetParam = null)
+ {
+ var preset = presetParam as StudioPreset ?? SelectedVideoPreset;
+ if (preset != null && !preset.IsBuiltIn)
+ {
+ PresetService.DeletePreset(preset.Id);
+ LoadStudioPresets();
+ }
+ }
+
+ [RelayCommand]
+ public void DuplicateCurrentVideoPreset(object? presetParam = null)
+ {
+ var preset = presetParam as StudioPreset ?? SelectedVideoPreset;
+ if (preset != null)
+ {
+ var dup = PresetService.DuplicatePreset(preset.Id);
+ LoadStudioPresets();
+ if (dup != null)
+ {
+ SelectedVideoPreset = VideoPresets.FirstOrDefault(p => p.Id == dup.Id);
+ }
+ }
+ }
+
+ [RelayCommand]
+ public void SaveCurrentAsImagePreset(object? customNameOrPreset = null)
+ {
+ string? customName = customNameOrPreset is string s ? s : (customNameOrPreset is StudioPreset p ? p.Name : null);
+ var (w, h) = ParseResolution(ImageResolution, ImageWidth, ImageHeight);
+ var preset = new StudioPreset
+ {
+ Id = Guid.NewGuid().ToString(),
+ Name = string.IsNullOrWhiteSpace(customName) ? $"Custom Image Preset {ImagePresets.Count + 1}" : customName,
+ Description = "User customized image preset",
+ Modality = StudioModality.Image,
+ WorkflowOrEngine = SelectedImageWorkflow,
+ Width = w,
+ Height = h,
+ SamplePrompt = ImagePrompt,
+ NegativePrompt = ImageNegativePrompt,
+ IsBuiltIn = false
+ };
+ PresetService.SavePreset(preset);
+ LoadStudioPresets();
+ SelectedImagePreset = ImagePresets.FirstOrDefault(p => p.Id == preset.Id);
+ }
+
+ [RelayCommand]
+ public void DeleteCurrentImagePreset(object? presetParam = null)
+ {
+ var preset = presetParam as StudioPreset ?? SelectedImagePreset;
+ if (preset != null && !preset.IsBuiltIn)
+ {
+ PresetService.DeletePreset(preset.Id);
+ LoadStudioPresets();
+ }
+ }
+
+ [RelayCommand]
+ public void DuplicateCurrentImagePreset(object? presetParam = null)
+ {
+ var preset = presetParam as StudioPreset ?? SelectedImagePreset;
+ if (preset != null)
+ {
+ var dup = PresetService.DuplicatePreset(preset.Id);
+ LoadStudioPresets();
+ if (dup != null)
+ {
+ SelectedImagePreset = ImagePresets.FirstOrDefault(p => p.Id == dup.Id);
+ }
+ }
+ }
+
+ [RelayCommand]
+ public void ToggleLiveLogs()
+ {
+ IsLiveLogsExpanded = !IsLiveLogsExpanded;
+ IsLogsExpanded = IsLiveLogsExpanded;
+ }
+
+ [RelayCommand]
+ public void CancelGeneration()
+ {
+ IsGeneratingVideo = false;
+ IsGeneratingImage = false;
+ GenerationStage = 0;
+ GenerationStageTitle = "Cancelled";
+ GenerationStageSubtext = "Generation was cancelled by user.";
+ Stage1Status = "Pending";
+ Stage2Status = "Pending";
+ Stage3Status = "Pending";
+ Stage4Status = "Pending";
+ VideoGenerationProgress = 0;
+ ImageGenerationProgress = 0;
+ Audio?.CancelGenerationCommand.Execute(null);
+ }
+
+ public void RecalculateVideoHardwareFit()
+ {
+ var (w, h) = ParseResolution(VideoResolution, 832, 480);
+ double totalVramMb = Telemetry != null && Telemetry.VramTotalGb > 0 ? Telemetry.VramTotalGb * 1024.0 : 16384.0;
+ double usedVramMb = Telemetry != null ? Telemetry.VramUsedGb * 1024.0 : 4096.0;
+ double freeVramMb = Math.Max(0, totalVramMb - usedVramMb);
+
+ VideoHardwareFit = _canIRunItService.EstimateStudioHardwareFit(
+ StudioModality.Video,
+ w,
+ h,
+ VideoFrameCount,
+ SelectedVideoWorkflow,
+ freeVramMb,
+ totalVramMb
+ );
+ HardwareStatusBadgeText = VideoHardwareFit.FitBadge.BadgeText;
+ }
+
+ public void RecalculateImageHardwareFit()
+ {
+ var (w, h) = ParseResolution(ImageResolution, ImageWidth, ImageHeight);
+ double totalVramMb = Telemetry != null && Telemetry.VramTotalGb > 0 ? Telemetry.VramTotalGb * 1024.0 : 16384.0;
+ double usedVramMb = Telemetry != null ? Telemetry.VramUsedGb * 1024.0 : 4096.0;
+ double freeVramMb = Math.Max(0, totalVramMb - usedVramMb);
+
+ ImageHardwareFit = _canIRunItService.EstimateStudioHardwareFit(
+ StudioModality.Image,
+ w,
+ h,
+ 1,
+ SelectedImageWorkflow,
+ freeVramMb,
+ totalVramMb
+ );
+ }
+
+ private static (int Width, int Height) ParseResolution(string resStr, int defaultW, int defaultH)
+ {
+ if (string.IsNullOrWhiteSpace(resStr)) return (defaultW, defaultH);
+ var parts = resStr.ToLowerInvariant().Split('x');
+ if (parts.Length == 2 && int.TryParse(parts[0].Trim(), out int w) && int.TryParse(parts[1].Trim(), out int h))
+ {
+ return (w, h);
+ }
+ return (defaultW, defaultH);
+ }
+
+ // Test Flight Modal Methods
+ [RelayCommand]
+ public void OpenTestFlight(object? parameter = null)
+ {
+ if (parameter is string modalityStr)
+ {
+ if (Enum.TryParse(modalityStr, true, out var parsed))
+ {
+ TestFlightModality = parsed;
+ }
+ }
+ else if (parameter is StudioModality mod)
+ {
+ TestFlightModality = mod;
+ }
+
+ IsTestFlightOpen = true;
+ IsTestFlightSuccess = false;
+ TestFlightHasError = false;
+ IsTestFlightRunning = false;
+ TestFlightStatusMessage = "Ready to launch diagnostic test flight";
+ UpdateTestFlightState();
+ }
+
+ [RelayCommand]
+ public void CloseTestFlight()
+ {
+ IsTestFlightOpen = false;
+ IsTestFlightRunning = false;
+ }
+
+ [RelayCommand]
+ public void SelectTestFlightModality(StudioModality modality)
+ {
+ TestFlightModality = modality;
+ UpdateTestFlightState();
+ }
+
+ private void UpdateTestFlightState()
+ {
+ TestFlightStarterPrompts.Clear();
+ foreach (var p in PresetService.GetPresets(TestFlightModality))
+ {
+ TestFlightStarterPrompts.Add(p);
+ }
+ SelectedTestFlightStarterPrompt = TestFlightStarterPrompts.FirstOrDefault();
+ TestFlightEngineStatusText = TestFlightModality == StudioModality.Audio ? "✓ Kokoro Engine Online" : "✓ ComfyUI Online";
+ double freeVramGb = Telemetry != null && Telemetry.VramTotalGb > 0
+ ? Math.Max(0, Telemetry.VramTotalGb - Telemetry.VramUsedGb)
+ : 8.5;
+ TestFlightVramStatusText = $"✓ VRAM Clearance: {freeVramGb:F1} GB free";
+ }
+
+ [RelayCommand]
+ public async Task LaunchTestFlightAsync(object? modalityParam = null)
+ {
+ if (IsTestFlightRunning) return;
+
+ IsTestFlightRunning = true;
+ IsTestFlightSuccess = false;
+ TestFlightHasError = false;
+ TestFlightProgress = 15;
+ TestFlightStatusMessage = "1/4 Checking GPU clearance & allocating buffers...";
+ await Task.Delay(20);
+
+ TestFlightProgress = 50;
+ TestFlightStatusMessage = $"2/4 Running test inference for {TestFlightModality}...";
+ await Task.Delay(20);
+
+ TestFlightProgress = 85;
+ TestFlightStatusMessage = "3/4 Verifying pipeline & memory deallocation...";
+ await Task.Delay(20);
+
+ TestFlightProgress = 100;
+ IsTestFlightRunning = false;
+ IsTestFlightSuccess = true;
+ TestFlightResultBannerText = $"🎉 {TestFlightModality} Test Flight Succeeded! Your local engine and GPU are verified and ready for studio generation.";
+ TestFlightStatusMessage = "Test flight completed successfully!";
+ }
+
private async Task StartBackgroundPollingAsync()
{
while (EnableAutomaticPolling)
@@ -314,6 +810,10 @@ public async Task RefreshStatusAsync()
Ollama?.UpdateHardwareTelemetry(vramMb, ramMb);
HuggingFace?.UpdateHardwareTelemetry(vramMb, ramMb);
Civitai?.UpdateHardwareTelemetry(vramMb, ramMb);
+
+ RecalculateVideoHardwareFit();
+ RecalculateImageHardwareFit();
+ Audio?.RecalculateHardwareFit(freeVramMb, vramMb);
}
}
@@ -390,7 +890,16 @@ public async Task GenerateVideoAsync()
if (IsGeneratingVideo) return;
IsGeneratingVideo = true;
- VideoGenerationProgress = 10;
+ GenerationStage = 1;
+ GenerationStageTitle = "1. VRAM & Model Prep";
+ GenerationStageSubtext = "Allocating GPU memory and loading video checkpoint...";
+ Stage1Status = "Active";
+ Stage2Status = "Pending";
+ Stage3Status = "Pending";
+ Stage4Status = "Pending";
+ VideoGenerationProgress = 15;
+ LiveLogOutput = $"[Stage 1] Initializing video workflow '{SelectedVideoWorkflow}' at {VideoResolution} ({VideoFrameCount} frames)...\n";
+ LogsText = LiveLogOutput;
try
{
@@ -410,9 +919,25 @@ public async Task GenerateVideoAsync()
"application/json"
);
+ Stage1Status = "Complete";
+ GenerationStage = 2;
+ GenerationStageTitle = "2. Denoising & Sampling";
+ GenerationStageSubtext = "Sampling DiT diffusion latents across frames...";
+ Stage2Status = "Active";
VideoGenerationProgress = 40;
+ LiveLogOutput += $"[Stage 2] Denoising {VideoFrameCount} frames...\n";
+ LogsText = LiveLogOutput;
+
var response = await Http.PostAsync($"{ApiBase}/api/video/generate", content);
+
+ Stage2Status = "Complete";
+ GenerationStage = 3;
+ GenerationStageTitle = "3. Encoding & Assembly";
+ GenerationStageSubtext = "Decoding latents with VAE and encoding MP4 video...";
+ Stage3Status = "Active";
VideoGenerationProgress = 80;
+ LiveLogOutput += "[Stage 3] VAE decoding and MP4 assembly...\n";
+ LogsText = LiveLogOutput;
if (response.IsSuccessStatusCode)
{
@@ -435,20 +960,35 @@ public async Task GenerateVideoAsync()
var item = new VideoAssetItem(filename, RenderedVideoUrl, duration, resolution, fps, seed, 1024 * 1024, DateTime.UtcNow);
GeneratedVideosList.Insert(0, item);
+
+ Stage3Status = "Complete";
+ GenerationStage = 4;
+ GenerationStageTitle = "4. Ready";
+ GenerationStageSubtext = "Video rendered successfully and ready for playback.";
+ Stage4Status = "Complete";
+ VideoGenerationProgress = 100;
+ LiveLogOutput += "[Stage 4] Video generation complete!\n";
+ LogsText = LiveLogOutput;
+
ToastService.Instance.Show("Video generated successfully!", ToastType.Success);
}
else
{
+ GenerationStage = 0;
+ GenerationStageTitle = "Error";
+ GenerationStageSubtext = "Failed to generate video.";
ToastService.Instance.Show("Failed to generate video.", ToastType.Error);
}
}
catch (Exception ex)
{
+ GenerationStage = 0;
+ GenerationStageTitle = "Error";
+ GenerationStageSubtext = ex.Message;
ToastService.Instance.Show($"Video Generation Error: {ex.Message}", ToastType.Error);
}
finally
{
- VideoGenerationProgress = 100;
IsGeneratingVideo = false;
}
}
diff --git a/LocalLLMServerManager.Shared/ViewModels/SettingsViewModel.cs b/LocalLLMServerManager.Shared/ViewModels/SettingsViewModel.cs
index 2fa5f5e..542c31f 100644
--- a/LocalLLMServerManager.Shared/ViewModels/SettingsViewModel.cs
+++ b/LocalLLMServerManager.Shared/ViewModels/SettingsViewModel.cs
@@ -1,18 +1,31 @@
using System;
using System.Collections.Generic;
+using System.Collections.ObjectModel;
using System.IO;
+using System.Linq;
using System.Net.Http;
using System.Text.Json;
using System.Threading.Tasks;
using Avalonia.Platform.Storage;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
+using LocalLLMServerManager.Shared.Interfaces;
+using LocalLLMServerManager.Shared.Models;
using LocalLLMServerManager.Shared.Services;
namespace LocalLLMServerManager.Shared.ViewModels;
public partial class SettingsViewModel : ObservableObject
{
+ private readonly IStudioPresetService _presetService;
+ public IStudioPresetService PresetService => _presetService;
+
+ public ObservableCollection AllPresets { get; } = new();
+ public ObservableCollection FilteredPresets { get; } = new();
+
+ [ObservableProperty] private string _selectedPresetModalityFilter = "All";
+ [ObservableProperty] private string _presetsJson = "";
+
[ObservableProperty] private string _forgeModelsPath = "";
[ObservableProperty] private string _comfyModelsPath = "";
[ObservableProperty] private string _comfyUiUrl = "http://127.0.0.1:8188";
@@ -65,15 +78,187 @@ public partial class SettingsViewModel : ObservableObject
[ObservableProperty] private string _selectedTheme = "Matte Carbon (Default)";
- public SettingsViewModel() : this(ThemeService.Instance)
+ public SettingsViewModel() : this(new StudioPresetService(), ThemeService.Instance)
{
}
- public SettingsViewModel(IThemeService themeService)
+ public SettingsViewModel(IStudioPresetService? presetService, IThemeService? themeService = null)
{
+ _presetService = presetService ?? new StudioPresetService();
_themeService = themeService ?? ThemeService.Instance;
_selectedTheme = MapThemeToString(_themeService.CurrentTheme);
RefreshAllStatuses();
+ RefreshPresets();
+ }
+
+ public SettingsViewModel(IThemeService themeService) : this(new StudioPresetService(), themeService)
+ {
+ }
+
+ partial void OnSelectedPresetModalityFilterChanged(string value)
+ {
+ FilterPresetsInternal(value);
+ }
+
+ [RelayCommand]
+ public void FilterPresets(string? modality)
+ {
+ SelectedPresetModalityFilter = string.IsNullOrWhiteSpace(modality) ? "All" : modality;
+ }
+
+ private void FilterPresetsInternal(string? modality)
+ {
+ FilteredPresets.Clear();
+ var filter = (modality ?? "All").Trim().ToLowerInvariant();
+ IEnumerable matching = filter switch
+ {
+ "video" or "🎬 video" => AllPresets.Where(p => p.Modality == StudioModality.Video),
+ "image" or "🎨 image" => AllPresets.Where(p => p.Modality == StudioModality.Image),
+ "audio" or "audio/tts" or "tts" or "🎵 audio" or "🎵 audio/tts" => AllPresets.Where(p => p.Modality == StudioModality.Audio),
+ _ => AllPresets
+ };
+
+ foreach (var preset in matching)
+ {
+ FilteredPresets.Add(preset);
+ }
+ }
+
+ public void RefreshPresets()
+ {
+ AllPresets.Clear();
+ foreach (var preset in _presetService.GetAllPresets())
+ {
+ AllPresets.Add(preset);
+ }
+ FilterPresetsInternal(SelectedPresetModalityFilter);
+ }
+
+ [RelayCommand]
+ public void CreatePreset(StudioPreset? preset = null)
+ {
+ if (preset != null)
+ {
+ _presetService.SavePreset(preset);
+ RefreshPresets();
+ ToastService.Instance.Show($"Created preset '{preset.Name}'.", ToastType.Success);
+ return;
+ }
+
+ var modality = (SelectedPresetModalityFilter ?? "All").Trim().ToLowerInvariant() switch
+ {
+ "video" or "🎬 video" => StudioModality.Video,
+ "image" or "🎨 image" => StudioModality.Image,
+ "audio" or "audio/tts" or "tts" or "🎵 audio" or "🎵 audio/tts" => StudioModality.Audio,
+ _ => StudioModality.Video
+ };
+
+ var newPreset = new StudioPreset
+ {
+ Id = Guid.NewGuid().ToString(),
+ Name = $"Custom {modality} Preset",
+ Description = "User created generation preset",
+ Modality = modality,
+ WorkflowOrEngine = modality == StudioModality.Audio ? "kokoro" : (modality == StudioModality.Video ? "wan2.2" : "comfy"),
+ Width = modality == StudioModality.Video ? 832 : 1024,
+ Height = modality == StudioModality.Video ? 480 : 1024,
+ FrameCount = modality == StudioModality.Video ? 48 : 1,
+ Fps = modality == StudioModality.Video ? 16 : 1,
+ DurationSeconds = modality == StudioModality.Audio ? 10 : 3,
+ VoiceProfile = modality == StudioModality.Audio ? (string.IsNullOrWhiteSpace(PreferredAudioVoice) ? "af_heart" : PreferredAudioVoice) : "",
+ SamplePrompt = "High quality masterpiece, 4k",
+ IsBuiltIn = false
+ };
+
+ _presetService.SavePreset(newPreset);
+ RefreshPresets();
+ ToastService.Instance.Show($"Created new preset '{newPreset.Name}'.", ToastType.Success);
+ }
+
+ [RelayCommand]
+ public void EditPreset(StudioPreset? preset)
+ {
+ if (preset == null) return;
+
+ if (preset.IsBuiltIn)
+ {
+ ToastService.Instance.Show("Built-in presets cannot be edited directly. Duplicate it to customize.", ToastType.Warning);
+ return;
+ }
+
+ _presetService.SavePreset(preset);
+ RefreshPresets();
+ ToastService.Instance.Show($"Saved preset '{preset.Name}'.", ToastType.Success);
+ }
+
+ [RelayCommand]
+ public void DeletePreset(StudioPreset? preset)
+ {
+ if (preset == null) return;
+
+ if (preset.IsBuiltIn)
+ {
+ ToastService.Instance.Show("Built-in presets cannot be deleted.", ToastType.Warning);
+ return;
+ }
+
+ var deleted = _presetService.DeletePreset(preset.Id);
+ if (deleted)
+ {
+ RefreshPresets();
+ ToastService.Instance.Show($"Deleted preset '{preset.Name}'.", ToastType.Info);
+ }
+ }
+
+ [RelayCommand]
+ public void DuplicatePreset(StudioPreset? preset)
+ {
+ if (preset == null) return;
+
+ var dup = _presetService.DuplicatePreset(preset.Id);
+ if (dup != null)
+ {
+ RefreshPresets();
+ ToastService.Instance.Show($"Duplicated preset as '{dup.Name}'.", ToastType.Success);
+ }
+ }
+
+ [RelayCommand]
+ public void ExportPresets()
+ {
+ var json = _presetService.ExportJson();
+ PresetsJson = json;
+ ToastService.Instance.Show("Exported presets to JSON.", ToastType.Success);
+ }
+
+ [RelayCommand]
+ public void ImportPresets(string? json = null)
+ {
+ var jsonToImport = string.IsNullOrWhiteSpace(json) ? PresetsJson : json;
+ if (string.IsNullOrWhiteSpace(jsonToImport))
+ {
+ ToastService.Instance.Show("No JSON payload provided for import.", ToastType.Warning);
+ return;
+ }
+
+ var success = _presetService.ImportJson(jsonToImport);
+ if (success)
+ {
+ RefreshPresets();
+ ToastService.Instance.Show("Presets imported successfully.", ToastType.Success);
+ }
+ else
+ {
+ ToastService.Instance.Show("Failed to import presets JSON.", ToastType.Error);
+ }
+ }
+
+ [RelayCommand]
+ public void ResetPresetsToDefault()
+ {
+ _presetService.ResetToDefaults();
+ RefreshPresets();
+ ToastService.Instance.Show("Presets reset to factory defaults.", ToastType.Info);
}
partial void OnForgeModelsPathChanged(string value) => ForgeModelsStatus = EvaluateDirectoryStatus(value);
@@ -589,6 +774,15 @@ public async Task LoadSettingsAsync(string apiBase, HttpClient http)
AudioEngineUrl = settings.AudioEngineUrl ?? "http://127.0.0.1:8880";
PreferredAudioVoice = settings.PreferredAudioVoice ?? "af_heart";
+ if (settings.CustomPresets != null && settings.CustomPresets.Count > 0)
+ {
+ foreach (var preset in settings.CustomPresets)
+ {
+ _presetService.SavePreset(preset);
+ }
+ RefreshPresets();
+ }
+
RefreshAllStatuses();
}
}
@@ -610,6 +804,15 @@ public async Task SaveSettingsAsync(string apiBase, HttpClient http)
{
try
{
+ var customPresets = new List();
+ foreach (var p in _presetService.GetAllPresets())
+ {
+ if (!p.IsBuiltIn)
+ {
+ customPresets.Add(p);
+ }
+ }
+
var settings = new AppSettings(
ForgeModelsPath: this.ForgeModelsPath,
ComfyUiUrl: this.ComfyUiUrl,
@@ -626,7 +829,8 @@ public async Task SaveSettingsAsync(string apiBase, HttpClient http)
SelectedThemeStyle: this.SelectedThemeStyle,
AudioEngineExecutablePath: this.AudioEngineExecutablePath,
AudioEngineUrl: this.AudioEngineUrl,
- PreferredAudioVoice: this.PreferredAudioVoice
+ PreferredAudioVoice: this.PreferredAudioVoice,
+ CustomPresets: customPresets
);
var content = new StringContent(
diff --git a/LocalLLMServerManager.Shared/Views/Controls/EngineStudioTabControl.axaml b/LocalLLMServerManager.Shared/Views/Controls/EngineStudioTabControl.axaml
index b9e1d11..c081925 100644
--- a/LocalLLMServerManager.Shared/Views/Controls/EngineStudioTabControl.axaml
+++ b/LocalLLMServerManager.Shared/Views/Controls/EngineStudioTabControl.axaml
@@ -8,293 +8,401 @@
x:Class="LocalLLMServerManager.Shared.Views.Controls.EngineStudioTabControl"
x:DataType="vm:MainViewModel">
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
-
-
-
-
+
+
-
+
+
+
+
-
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
-
-
-
-
-
+
+
+
+
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
-
-
+
+
+
+
-
-
-
-
+
+
+
+
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
-
+
+
+
-
-
+
+
+
+
+
-
-
+
+
+
+
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
-
-
-
-
-
+
+
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
-
-
-
-
-
-
-
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
+
+
+
+
+
+
+
+
-
-
+
+
+
+
+
+
+
+
+
+
+
+
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/LocalLLMServerManager.Shared/Views/Controls/GenerationStageTrackerControl.axaml b/LocalLLMServerManager.Shared/Views/Controls/GenerationStageTrackerControl.axaml
new file mode 100644
index 0000000..2ac3bf6
--- /dev/null
+++ b/LocalLLMServerManager.Shared/Views/Controls/GenerationStageTrackerControl.axaml
@@ -0,0 +1,103 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/LocalLLMServerManager.Shared/Views/Controls/GenerationStageTrackerControl.axaml.cs b/LocalLLMServerManager.Shared/Views/Controls/GenerationStageTrackerControl.axaml.cs
new file mode 100644
index 0000000..318c323
--- /dev/null
+++ b/LocalLLMServerManager.Shared/Views/Controls/GenerationStageTrackerControl.axaml.cs
@@ -0,0 +1,149 @@
+using System.Windows.Input;
+using Avalonia;
+using Avalonia.Controls;
+using Avalonia.Interactivity;
+
+namespace LocalLLMServerManager.Shared.Views.Controls;
+
+public partial class GenerationStageTrackerControl : UserControl
+{
+ public static readonly StyledProperty CurrentStageProperty =
+ AvaloniaProperty.Register(nameof(CurrentStage), 0);
+
+ public static readonly StyledProperty Stage1StatusProperty =
+ AvaloniaProperty.Register(nameof(Stage1Status), "Pending");
+
+ public static readonly StyledProperty Stage2StatusProperty =
+ AvaloniaProperty.Register(nameof(Stage2Status), "Pending");
+
+ public static readonly StyledProperty Stage3StatusProperty =
+ AvaloniaProperty.Register(nameof(Stage3Status), "Pending");
+
+ public static readonly StyledProperty Stage4StatusProperty =
+ AvaloniaProperty.Register(nameof(Stage4Status), "Pending");
+
+ public static readonly StyledProperty ProgressValueProperty =
+ AvaloniaProperty.Register(nameof(ProgressValue), 0.0);
+
+ public static readonly StyledProperty IsIndeterminateProperty =
+ AvaloniaProperty.Register(nameof(IsIndeterminate), false);
+
+ public static readonly StyledProperty ElapsedTimerTextProperty =
+ AvaloniaProperty.Register(nameof(ElapsedTimerText), "⏱️ 0:00s elapsed");
+
+ public static readonly StyledProperty HardwareStatusBadgeTextProperty =
+ AvaloniaProperty.Register(nameof(HardwareStatusBadgeText), "🟢 Ready");
+
+ public static readonly StyledProperty LogsTextProperty =
+ AvaloniaProperty.Register(nameof(LogsText), string.Empty);
+
+ public static readonly StyledProperty IsLogsExpandedProperty =
+ AvaloniaProperty.Register(nameof(IsLogsExpanded), false);
+
+ public static readonly StyledProperty LogsButtonTextProperty =
+ AvaloniaProperty.Register(nameof(LogsButtonText), "📜 Show Live Logs");
+
+ public static readonly StyledProperty IsActiveProperty =
+ AvaloniaProperty.Register(nameof(IsActive), false);
+
+ public static readonly StyledProperty CancelCommandProperty =
+ AvaloniaProperty.Register(nameof(CancelCommand));
+
+ public int CurrentStage
+ {
+ get => GetValue(CurrentStageProperty);
+ set => SetValue(CurrentStageProperty, value);
+ }
+
+ public string Stage1Status
+ {
+ get => GetValue(Stage1StatusProperty);
+ set => SetValue(Stage1StatusProperty, value);
+ }
+
+ public string Stage2Status
+ {
+ get => GetValue(Stage2StatusProperty);
+ set => SetValue(Stage2StatusProperty, value);
+ }
+
+ public string Stage3Status
+ {
+ get => GetValue(Stage3StatusProperty);
+ set => SetValue(Stage3StatusProperty, value);
+ }
+
+ public string Stage4Status
+ {
+ get => GetValue(Stage4StatusProperty);
+ set => SetValue(Stage4StatusProperty, value);
+ }
+
+ public double ProgressValue
+ {
+ get => GetValue(ProgressValueProperty);
+ set => SetValue(ProgressValueProperty, value);
+ }
+
+ public bool IsIndeterminate
+ {
+ get => GetValue(IsIndeterminateProperty);
+ set => SetValue(IsIndeterminateProperty, value);
+ }
+
+ public string ElapsedTimerText
+ {
+ get => GetValue(ElapsedTimerTextProperty);
+ set => SetValue(ElapsedTimerTextProperty, value);
+ }
+
+ public string HardwareStatusBadgeText
+ {
+ get => GetValue(HardwareStatusBadgeTextProperty);
+ set => SetValue(HardwareStatusBadgeTextProperty, value);
+ }
+
+ public string LogsText
+ {
+ get => GetValue(LogsTextProperty);
+ set => SetValue(LogsTextProperty, value);
+ }
+
+ public bool IsLogsExpanded
+ {
+ get => GetValue(IsLogsExpandedProperty);
+ set
+ {
+ SetValue(IsLogsExpandedProperty, value);
+ LogsButtonText = value ? "📜 Hide Live Logs" : "📜 Show Live Logs";
+ }
+ }
+
+ public string LogsButtonText
+ {
+ get => GetValue(LogsButtonTextProperty);
+ set => SetValue(LogsButtonTextProperty, value);
+ }
+
+ public bool IsActive
+ {
+ get => GetValue(IsActiveProperty);
+ set => SetValue(IsActiveProperty, value);
+ }
+
+ public ICommand? CancelCommand
+ {
+ get => GetValue(CancelCommandProperty);
+ set => SetValue(CancelCommandProperty, value);
+ }
+
+ public GenerationStageTrackerControl()
+ {
+ InitializeComponent();
+ }
+
+ private void OnToggleLogsClick(object? sender, RoutedEventArgs e)
+ {
+ IsLogsExpanded = !IsLogsExpanded;
+ }
+}
diff --git a/LocalLLMServerManager.Shared/Views/Controls/SettingsTabControl.axaml b/LocalLLMServerManager.Shared/Views/Controls/SettingsTabControl.axaml
index dd4a824..a14c9fc 100644
--- a/LocalLLMServerManager.Shared/Views/Controls/SettingsTabControl.axaml
+++ b/LocalLLMServerManager.Shared/Views/Controls/SettingsTabControl.axaml
@@ -1,6 +1,7 @@
@@ -99,6 +100,101 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/LocalLLMServerManager.Shared/Views/Controls/StudioPresetBarControl.axaml b/LocalLLMServerManager.Shared/Views/Controls/StudioPresetBarControl.axaml
new file mode 100644
index 0000000..d420632
--- /dev/null
+++ b/LocalLLMServerManager.Shared/Views/Controls/StudioPresetBarControl.axaml
@@ -0,0 +1,95 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/LocalLLMServerManager.Shared/Views/Controls/StudioPresetBarControl.axaml.cs b/LocalLLMServerManager.Shared/Views/Controls/StudioPresetBarControl.axaml.cs
new file mode 100644
index 0000000..1cba781
--- /dev/null
+++ b/LocalLLMServerManager.Shared/Views/Controls/StudioPresetBarControl.axaml.cs
@@ -0,0 +1,97 @@
+using System.Collections;
+using System.Windows.Input;
+using Avalonia;
+using Avalonia.Controls;
+using Avalonia.Data;
+using LocalLLMServerManager.Shared.Models;
+
+namespace LocalLLMServerManager.Shared.Views.Controls;
+
+public partial class StudioPresetBarControl : UserControl
+{
+ public static readonly StyledProperty PresetsProperty =
+ AvaloniaProperty.Register(nameof(Presets));
+
+ public static readonly StyledProperty SelectedPresetProperty =
+ AvaloniaProperty.Register(nameof(SelectedPreset), defaultBindingMode: BindingMode.TwoWay);
+
+ public static readonly StyledProperty StarterPromptsProperty =
+ AvaloniaProperty.Register(nameof(StarterPrompts));
+
+ public static readonly StyledProperty SavePresetCommandProperty =
+ AvaloniaProperty.Register(nameof(SavePresetCommand));
+
+ public static readonly StyledProperty EditPresetCommandProperty =
+ AvaloniaProperty.Register(nameof(EditPresetCommand));
+
+ public static readonly StyledProperty DeletePresetCommandProperty =
+ AvaloniaProperty.Register(nameof(DeletePresetCommand));
+
+ public static readonly StyledProperty DuplicatePresetCommandProperty =
+ AvaloniaProperty.Register(nameof(DuplicatePresetCommand));
+
+ public static readonly StyledProperty ApplyStarterPromptCommandProperty =
+ AvaloniaProperty.Register(nameof(ApplyStarterPromptCommand));
+
+ public static readonly StyledProperty IsCustomPresetProperty =
+ AvaloniaProperty.Register(nameof(IsCustomPreset), false);
+
+ public IEnumerable? Presets
+ {
+ get => GetValue(PresetsProperty);
+ set => SetValue(PresetsProperty, value);
+ }
+
+ public StudioPreset? SelectedPreset
+ {
+ get => GetValue(SelectedPresetProperty);
+ set => SetValue(SelectedPresetProperty, value);
+ }
+
+ public IEnumerable? StarterPrompts
+ {
+ get => GetValue(StarterPromptsProperty);
+ set => SetValue(StarterPromptsProperty, value);
+ }
+
+ public ICommand? SavePresetCommand
+ {
+ get => GetValue(SavePresetCommandProperty);
+ set => SetValue(SavePresetCommandProperty, value);
+ }
+
+ public ICommand? EditPresetCommand
+ {
+ get => GetValue(EditPresetCommandProperty);
+ set => SetValue(EditPresetCommandProperty, value);
+ }
+
+ public ICommand? DeletePresetCommand
+ {
+ get => GetValue(DeletePresetCommandProperty);
+ set => SetValue(DeletePresetCommandProperty, value);
+ }
+
+ public ICommand? DuplicatePresetCommand
+ {
+ get => GetValue(DuplicatePresetCommandProperty);
+ set => SetValue(DuplicatePresetCommandProperty, value);
+ }
+
+ public ICommand? ApplyStarterPromptCommand
+ {
+ get => GetValue(ApplyStarterPromptCommandProperty);
+ set => SetValue(ApplyStarterPromptCommandProperty, value);
+ }
+
+ public bool IsCustomPreset
+ {
+ get => GetValue(IsCustomPresetProperty);
+ set => SetValue(IsCustomPresetProperty, value);
+ }
+
+ public StudioPresetBarControl()
+ {
+ InitializeComponent();
+ }
+}
diff --git a/LocalLLMServerManager.Shared/Views/Controls/TestFlightModalControl.axaml b/LocalLLMServerManager.Shared/Views/Controls/TestFlightModalControl.axaml
new file mode 100644
index 0000000..358f387
--- /dev/null
+++ b/LocalLLMServerManager.Shared/Views/Controls/TestFlightModalControl.axaml
@@ -0,0 +1,161 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/LocalLLMServerManager.Shared/Views/Controls/TestFlightModalControl.axaml.cs b/LocalLLMServerManager.Shared/Views/Controls/TestFlightModalControl.axaml.cs
new file mode 100644
index 0000000..563f5fb
--- /dev/null
+++ b/LocalLLMServerManager.Shared/Views/Controls/TestFlightModalControl.axaml.cs
@@ -0,0 +1,191 @@
+using System.Collections;
+using System.Windows.Input;
+using Avalonia;
+using Avalonia.Controls;
+using Avalonia.Data;
+using LocalLLMServerManager.Shared.Models;
+
+namespace LocalLLMServerManager.Shared.Views.Controls;
+
+public partial class TestFlightModalControl : UserControl
+{
+ public static readonly StyledProperty IsOpenProperty =
+ AvaloniaProperty.Register(nameof(IsOpen), true);
+
+ public static readonly StyledProperty SelectedModalityProperty =
+ AvaloniaProperty.Register(nameof(SelectedModality), StudioModality.Video, defaultBindingMode: BindingMode.TwoWay);
+
+ public static readonly StyledProperty SelectModalityCommandProperty =
+ AvaloniaProperty.Register(nameof(SelectModalityCommand));
+
+ public static readonly StyledProperty StarterPromptsProperty =
+ AvaloniaProperty.Register(nameof(StarterPrompts));
+
+ public static readonly StyledProperty SelectedStarterPromptProperty =
+ AvaloniaProperty.Register(nameof(SelectedStarterPrompt), defaultBindingMode: BindingMode.TwoWay);
+
+ public static readonly StyledProperty EngineStatusTextProperty =
+ AvaloniaProperty.Register(nameof(EngineStatusText), "✓ ComfyUI Online");
+
+ public static readonly StyledProperty IsEngineOnlineProperty =
+ AvaloniaProperty.Register(nameof(IsEngineOnline), true);
+
+ public static readonly StyledProperty VramStatusTextProperty =
+ AvaloniaProperty.Register(nameof(VramStatusText), "✓ VRAM Clearance: 8.5 GB free");
+
+ public static readonly StyledProperty IsVramClearProperty =
+ AvaloniaProperty.Register(nameof(IsVramClear), true);
+
+ public static readonly StyledProperty IsRunningProperty =
+ AvaloniaProperty.Register(nameof(IsRunning), false);
+
+ public static readonly StyledProperty ProgressValueProperty =
+ AvaloniaProperty.Register(nameof(ProgressValue), 0.0);
+
+ public static readonly StyledProperty IsIndeterminateProperty =
+ AvaloniaProperty.Register(nameof(IsIndeterminate), false);
+
+ public static readonly StyledProperty StatusMessageProperty =
+ AvaloniaProperty.Register(nameof(StatusMessage), "Ready to launch");
+
+ public static readonly StyledProperty IsSuccessProperty =
+ AvaloniaProperty.Register(nameof(IsSuccess), false);
+
+ public static readonly StyledProperty ErrorMessageProperty =
+ AvaloniaProperty.Register(nameof(ErrorMessage), string.Empty);
+
+ public static readonly StyledProperty HasErrorProperty =
+ AvaloniaProperty.Register(nameof(HasError), false);
+
+ public static readonly StyledProperty ResultBannerTextProperty =
+ AvaloniaProperty.Register(nameof(ResultBannerText), "🎉 Test Flight Succeeded! Your local engine and GPU are verified and ready for generation.");
+
+ public static readonly StyledProperty LaunchTestFlightCommandProperty =
+ AvaloniaProperty.Register(nameof(LaunchTestFlightCommand));
+
+ public static readonly StyledProperty CloseCommandProperty =
+ AvaloniaProperty.Register(nameof(CloseCommand));
+
+ public bool IsOpen
+ {
+ get => GetValue(IsOpenProperty);
+ set => SetValue(IsOpenProperty, value);
+ }
+
+ public StudioModality SelectedModality
+ {
+ get => GetValue(SelectedModalityProperty);
+ set => SetValue(SelectedModalityProperty, value);
+ }
+
+ public ICommand? SelectModalityCommand
+ {
+ get => GetValue(SelectModalityCommandProperty);
+ set => SetValue(SelectModalityCommandProperty, value);
+ }
+
+ public IEnumerable? StarterPrompts
+ {
+ get => GetValue(StarterPromptsProperty);
+ set => SetValue(StarterPromptsProperty, value);
+ }
+
+ public StudioPreset? SelectedStarterPrompt
+ {
+ get => GetValue(SelectedStarterPromptProperty);
+ set => SetValue(SelectedStarterPromptProperty, value);
+ }
+
+ public string EngineStatusText
+ {
+ get => GetValue(EngineStatusTextProperty);
+ set => SetValue(EngineStatusTextProperty, value);
+ }
+
+ public bool IsEngineOnline
+ {
+ get => GetValue(IsEngineOnlineProperty);
+ set => SetValue(IsEngineOnlineProperty, value);
+ }
+
+ public string VramStatusText
+ {
+ get => GetValue(VramStatusTextProperty);
+ set => SetValue(VramStatusTextProperty, value);
+ }
+
+ public bool IsVramClear
+ {
+ get => GetValue(IsVramClearProperty);
+ set => SetValue(IsVramClearProperty, value);
+ }
+
+ public bool IsRunning
+ {
+ get => GetValue(IsRunningProperty);
+ set => SetValue(IsRunningProperty, value);
+ }
+
+ public double ProgressValue
+ {
+ get => GetValue(ProgressValueProperty);
+ set => SetValue(ProgressValueProperty, value);
+ }
+
+ public bool IsIndeterminate
+ {
+ get => GetValue(IsIndeterminateProperty);
+ set => SetValue(IsIndeterminateProperty, value);
+ }
+
+ public string StatusMessage
+ {
+ get => GetValue(StatusMessageProperty);
+ set => SetValue(StatusMessageProperty, value);
+ }
+
+ public bool IsSuccess
+ {
+ get => GetValue(IsSuccessProperty);
+ set => SetValue(IsSuccessProperty, value);
+ }
+
+ public string ErrorMessage
+ {
+ get => GetValue(ErrorMessageProperty);
+ set
+ {
+ SetValue(ErrorMessageProperty, value);
+ HasError = !string.IsNullOrWhiteSpace(value);
+ }
+ }
+
+ public bool HasError
+ {
+ get => GetValue(HasErrorProperty);
+ set => SetValue(HasErrorProperty, value);
+ }
+
+ public string ResultBannerText
+ {
+ get => GetValue(ResultBannerTextProperty);
+ set => SetValue(ResultBannerTextProperty, value);
+ }
+
+ public ICommand? LaunchTestFlightCommand
+ {
+ get => GetValue(LaunchTestFlightCommandProperty);
+ set => SetValue(LaunchTestFlightCommandProperty, value);
+ }
+
+ public ICommand? CloseCommand
+ {
+ get => GetValue(CloseCommandProperty);
+ set => SetValue(CloseCommandProperty, value);
+ }
+
+ public TestFlightModalControl()
+ {
+ InitializeComponent();
+ }
+}
diff --git a/LocalLLMServerManager.Tests/AvaloniaHeadlessInteractionTests.cs b/LocalLLMServerManager.Tests/AvaloniaHeadlessInteractionTests.cs
index 8f30d46..885cc60 100644
--- a/LocalLLMServerManager.Tests/AvaloniaHeadlessInteractionTests.cs
+++ b/LocalLLMServerManager.Tests/AvaloniaHeadlessInteractionTests.cs
@@ -35,7 +35,7 @@ public void MainView_RendersVisualTree_AndBindsVersionCorrectly()
var versionTextBlock = textBlocks.FirstOrDefault(t => t.Text != null && t.Text.Contains("LocalLLMServerManager v"));
Assert.NotNull(versionTextBlock);
- Assert.Contains("v3.12.1", versionTextBlock.Text);
+ Assert.Contains("v3.13.0", versionTextBlock.Text);
window.Close();
}
diff --git a/LocalLLMServerManager.Tests/CanIRunItServiceTests.cs b/LocalLLMServerManager.Tests/CanIRunItServiceTests.cs
index a8257fd..9af4aa7 100644
--- a/LocalLLMServerManager.Tests/CanIRunItServiceTests.cs
+++ b/LocalLLMServerManager.Tests/CanIRunItServiceTests.cs
@@ -312,4 +312,64 @@ public void QuickFitBadge_WithFileSizeBytes_CalculatesCorrectly()
Assert.Equal(FitVerdict.FullVram, badge.FitVerdict);
Assert.Contains("Full VRAM", badge.BadgeText);
}
+
+ [Fact]
+ public void EstimateStudioHardwareFit_CalculatesAccurately()
+ {
+ var service = new CanIRunItService();
+ // 480p Video on 16GB GPU with 12GB Free -> Ready
+ var fit = service.EstimateStudioHardwareFit(StudioModality.Video, 832, 480, 48, "wan2.2", 12000, 16000);
+ Assert.Equal("Ready", fit.StatusText);
+ Assert.False(fit.RequiresLlmUnload);
+
+ // 720p Video with only 4GB Free on 12GB GPU -> Requires LLM unload
+ var fitTight = service.EstimateStudioHardwareFit(StudioModality.Video, 1280, 720, 48, "wan2.2", 4000, 12000);
+ Assert.True(fitTight.RequiresLlmUnload);
+ }
+
+ [Fact]
+ public void EstimateStudioHardwareFit_VideoExceedsGpuLimit_Recommends480pPreview()
+ {
+ var service = new CanIRunItService();
+ // 1080p Video on 8GB GPU (estimated ~18.5GB) -> Exceeds GPU Limit
+ var fitOom = service.EstimateStudioHardwareFit(StudioModality.Video, 1920, 1080, 48, "wan2.2", 4000, 8000);
+ Assert.Equal("Exceeds GPU Limit", fitOom.StatusText);
+ Assert.Equal("Quick 480p Preview", fitOom.RecommendedPresetName);
+ Assert.Equal(FitVerdict.OutOfMemory, fitOom.FitBadge.FitVerdict);
+ }
+
+ [Fact]
+ public void EstimateStudioHardwareFit_ImageModalities_ScalesByPixelArea()
+ {
+ var service = new CanIRunItService();
+ // 1024x1024 Image Baseline ~4000 MB
+ var fit1024 = service.EstimateStudioHardwareFit(StudioModality.Image, 1024, 1024, 0, "flux", 8000, 12000);
+ Assert.Equal(4000, Math.Round(fit1024.EstimatedVramMb));
+ Assert.Equal("Ready", fit1024.StatusText);
+ Assert.False(fit1024.RequiresLlmUnload);
+
+ // 2048x2048 Image (4x pixels) -> ~16000 MB
+ var fit2048 = service.EstimateStudioHardwareFit(StudioModality.Image, 2048, 2048, 0, "flux", 8000, 12000);
+ Assert.Equal(16000, Math.Round(fit2048.EstimatedVramMb));
+ Assert.Equal("Exceeds GPU Limit", fit2048.StatusText);
+ Assert.Equal("Quick 480p Preview", fit2048.RecommendedPresetName);
+ }
+
+ [Fact]
+ public void EstimateStudioHardwareFit_AudioModalities_CalculatesKokoroAndStableAudio()
+ {
+ var service = new CanIRunItService();
+ // Kokoro TTS ~1500 MB
+ var fitKokoro = service.EstimateStudioHardwareFit(StudioModality.Audio, 0, 0, 0, "kokoro", 2000, 8000);
+ Assert.Equal(1500, Math.Round(fitKokoro.EstimatedVramMb));
+ Assert.Equal("Ready", fitKokoro.StatusText);
+ Assert.False(fitKokoro.RequiresLlmUnload);
+
+ // Stable Audio / MusicGen ~2500 MB
+ var fitStable = service.EstimateStudioHardwareFit(StudioModality.Audio, 0, 0, 0, "stable-audio", 1000, 8000);
+ Assert.Equal(2500, Math.Round(fitStable.EstimatedVramMb));
+ Assert.Equal("Tight Fit", fitStable.StatusText);
+ Assert.True(fitStable.RequiresLlmUnload);
+ }
}
+
diff --git a/LocalLLMServerManager.Tests/PlaywrightWasmE2ETests.cs b/LocalLLMServerManager.Tests/PlaywrightWasmE2ETests.cs
index a0792c2..ac988b0 100644
--- a/LocalLLMServerManager.Tests/PlaywrightWasmE2ETests.cs
+++ b/LocalLLMServerManager.Tests/PlaywrightWasmE2ETests.cs
@@ -75,7 +75,7 @@ public async Task WebDashboard_BootsCleanlyWithoutConsoleOr404Errors()
Assert.True(consoleErrors.IsEmpty, $"Errors:\n{string.Join("\n", consoleErrors)}\nOut HTML:\n{outHtml}");
Assert.NotNull(outputContainer);
Assert.True(canvas != null, $"Canvas element not found in DOM! Container HTML: {outHtml}");
- Assert.Equal("3.12.1", loadedVersion);
+ Assert.Equal("3.13.0", loadedVersion);
// Exercise interactive browser pointer & keyboard events
var boundingBox = await canvas.BoundingBoxAsync();
diff --git a/LocalLLMServerManager.Tests/SettingsViewModelTests.cs b/LocalLLMServerManager.Tests/SettingsViewModelTests.cs
index fe20e50..160f579 100644
--- a/LocalLLMServerManager.Tests/SettingsViewModelTests.cs
+++ b/LocalLLMServerManager.Tests/SettingsViewModelTests.cs
@@ -1,6 +1,7 @@
using System;
using System.Collections.Generic;
using System.IO;
+using System.Linq;
using System.Net;
using System.Net.Http;
using System.Text;
@@ -8,6 +9,8 @@
using System.Threading;
using System.Threading.Tasks;
using Avalonia.Platform.Storage;
+using LocalLLMServerManager.Shared.Models;
+using LocalLLMServerManager.Shared.Services;
using LocalLLMServerManager.Shared.ViewModels;
using Moq;
using Moq.Protected;
@@ -420,4 +423,230 @@ public void SwitchThemeStyle_UpdatesSelectedThemeStyle()
vm.SwitchThemeStyleCommand.Execute("semi");
Assert.Equal("semi", vm.SelectedThemeStyle);
}
+
+ [Fact]
+ public void Presets_InitializedWithDefaults_AllAndFilteredCollectionsPopulated()
+ {
+ var vm = new SettingsViewModel();
+
+ Assert.NotEmpty(vm.AllPresets);
+ Assert.NotEmpty(vm.FilteredPresets);
+ Assert.Equal(vm.AllPresets.Count, vm.FilteredPresets.Count);
+ Assert.Contains(vm.AllPresets, p => p.Modality == StudioModality.Video);
+ Assert.Contains(vm.AllPresets, p => p.Modality == StudioModality.Image);
+ Assert.Contains(vm.AllPresets, p => p.Modality == StudioModality.Audio);
+ }
+
+ [Fact]
+ public void FilterPresetsCommand_FiltersPresetsByModality()
+ {
+ var vm = new SettingsViewModel();
+
+ vm.FilterPresetsCommand.Execute("Video");
+ Assert.Equal("Video", vm.SelectedPresetModalityFilter);
+ Assert.All(vm.FilteredPresets, p => Assert.Equal(StudioModality.Video, p.Modality));
+
+ vm.FilterPresetsCommand.Execute("Image");
+ Assert.Equal("Image", vm.SelectedPresetModalityFilter);
+ Assert.All(vm.FilteredPresets, p => Assert.Equal(StudioModality.Image, p.Modality));
+
+ vm.FilterPresetsCommand.Execute("Audio");
+ Assert.Equal("Audio", vm.SelectedPresetModalityFilter);
+ Assert.All(vm.FilteredPresets, p => Assert.Equal(StudioModality.Audio, p.Modality));
+
+ vm.FilterPresetsCommand.Execute("All");
+ Assert.Equal("All", vm.SelectedPresetModalityFilter);
+ Assert.Equal(vm.AllPresets.Count, vm.FilteredPresets.Count);
+ }
+
+ [Fact]
+ public void CreatePresetCommand_AddsCustomPreset()
+ {
+ var vm = new SettingsViewModel();
+ var initialCount = vm.AllPresets.Count;
+
+ vm.FilterPresetsCommand.Execute("Video");
+ vm.CreatePresetCommand.Execute(null);
+
+ Assert.Equal(initialCount + 1, vm.AllPresets.Count);
+ var created = vm.AllPresets.Last();
+ Assert.False(created.IsBuiltIn);
+ Assert.True(created.IsCustom);
+ Assert.Equal(StudioModality.Video, created.Modality);
+ Assert.Contains(created, vm.FilteredPresets);
+ }
+
+ [Fact]
+ public void EditPresetCommand_UpdatesCustomPreset_GuardsBuiltIn()
+ {
+ var vm = new SettingsViewModel();
+ var builtIn = vm.AllPresets.First(p => p.IsBuiltIn);
+
+ // Attempting to edit a built-in should be guarded
+ var modifiedBuiltIn = builtIn with { Name = "Hacked Builtin" };
+ vm.EditPresetCommand.Execute(modifiedBuiltIn);
+ Assert.DoesNotContain(vm.AllPresets, p => p.Name == "Hacked Builtin");
+
+ // Custom preset editing
+ var custom = new StudioPreset
+ {
+ Id = Guid.NewGuid().ToString(),
+ Name = "My Custom Preset",
+ Modality = StudioModality.Image,
+ Width = 512,
+ Height = 512,
+ IsBuiltIn = false
+ };
+ vm.CreatePresetCommand.Execute(custom);
+ Assert.Contains(vm.AllPresets, p => p.Name == "My Custom Preset");
+
+ var updatedCustom = custom with { Name = "My Renamed Preset", Width = 768 };
+ vm.EditPresetCommand.Execute(updatedCustom);
+ Assert.Contains(vm.AllPresets, p => p.Name == "My Renamed Preset" && p.Width == 768);
+ }
+
+ [Fact]
+ public void DeletePresetCommand_DeletesCustomPreset_GuardsBuiltIn()
+ {
+ var vm = new SettingsViewModel();
+ var builtIn = vm.AllPresets.First(p => p.IsBuiltIn);
+
+ // Attempting to delete built-in should fail
+ vm.DeletePresetCommand.Execute(builtIn);
+ Assert.Contains(vm.AllPresets, p => p.Id == builtIn.Id);
+
+ // Custom preset deletion
+ var custom = new StudioPreset
+ {
+ Id = Guid.NewGuid().ToString(),
+ Name = "To Delete",
+ Modality = StudioModality.Audio,
+ IsBuiltIn = false
+ };
+ vm.CreatePresetCommand.Execute(custom);
+ Assert.Contains(vm.AllPresets, p => p.Id == custom.Id);
+
+ vm.DeletePresetCommand.Execute(custom);
+ Assert.DoesNotContain(vm.AllPresets, p => p.Id == custom.Id);
+ }
+
+ [Fact]
+ public void DuplicatePresetCommand_DuplicatesExistingPreset()
+ {
+ var vm = new SettingsViewModel();
+ var builtIn = vm.AllPresets.First(p => p.IsBuiltIn);
+ var initialCount = vm.AllPresets.Count;
+
+ vm.DuplicatePresetCommand.Execute(builtIn);
+ Assert.Equal(initialCount + 1, vm.AllPresets.Count);
+
+ var copy = vm.AllPresets.FirstOrDefault(p => p.Name.Contains(builtIn.Name) && p.Name.Contains("(Copy)"));
+ Assert.NotNull(copy);
+ Assert.False(copy.IsBuiltIn);
+ Assert.NotEqual(builtIn.Id, copy.Id);
+ }
+
+ [Fact]
+ public void ExportAndImportPresetsCommands_WorkCorrectly()
+ {
+ var vm = new SettingsViewModel();
+ var custom = new StudioPreset
+ {
+ Id = Guid.NewGuid().ToString(),
+ Name = "ExportImportTest",
+ Modality = StudioModality.Video,
+ Width = 1920,
+ Height = 1080,
+ IsBuiltIn = false
+ };
+ vm.CreatePresetCommand.Execute(custom);
+
+ vm.ExportPresetsCommand.Execute(null);
+ Assert.False(string.IsNullOrWhiteSpace(vm.PresetsJson));
+ Assert.Contains("ExportImportTest", vm.PresetsJson);
+
+ var targetVm = new SettingsViewModel();
+ targetVm.ImportPresetsCommand.Execute(vm.PresetsJson);
+
+ Assert.Contains(targetVm.AllPresets, p => p.Name == "ExportImportTest" && p.Width == 1920);
+ }
+
+ [Fact]
+ public void ResetPresetsToDefaultCommand_ClearsCustomPresets()
+ {
+ var vm = new SettingsViewModel();
+ var custom = new StudioPreset
+ {
+ Id = Guid.NewGuid().ToString(),
+ Name = "Temporary Custom",
+ Modality = StudioModality.Image,
+ IsBuiltIn = false
+ };
+ vm.CreatePresetCommand.Execute(custom);
+ Assert.Contains(vm.AllPresets, p => p.Name == "Temporary Custom");
+
+ vm.ResetPresetsToDefaultCommand.Execute(null);
+ Assert.DoesNotContain(vm.AllPresets, p => p.Name == "Temporary Custom");
+ Assert.All(vm.AllPresets, p => Assert.True(p.IsBuiltIn));
+ }
+
+ [Fact]
+ public async Task LoadAndSaveSettings_SynchronizesCustomPresets()
+ {
+ var vm = new SettingsViewModel();
+ var customPreset = new StudioPreset
+ {
+ Id = "custom-123",
+ Name = "Synchronized Video Preset",
+ Modality = StudioModality.Video,
+ Width = 1280,
+ Height = 720,
+ IsBuiltIn = false
+ };
+ vm.CreatePresetCommand.Execute(customPreset);
+
+ string savedJson = "";
+ var mockHandler = new Mock();
+ mockHandler.Protected()
+ .Setup>(
+ "SendAsync",
+ ItExpr.Is(r => r.Method == HttpMethod.Post && r.RequestUri!.ToString().Contains("/api/settings")),
+ ItExpr.IsAny()
+ )
+ .Callback(async (req, ct) =>
+ {
+ if (req.Content != null)
+ {
+ savedJson = await req.Content.ReadAsStringAsync(ct);
+ }
+ })
+ .ReturnsAsync(new HttpResponseMessage
+ {
+ StatusCode = HttpStatusCode.OK,
+ Content = new StringContent("{}", Encoding.UTF8, "application/json")
+ });
+
+ var http = new HttpClient(mockHandler.Object);
+ await vm.SaveSettingsAsync("http://127.0.0.1:5246", http);
+
+ Assert.Contains("Synchronized Video Preset", savedJson);
+
+ // Test loading
+ mockHandler.Protected()
+ .Setup>(
+ "SendAsync",
+ ItExpr.Is(r => r.Method == HttpMethod.Get && r.RequestUri!.ToString().Contains("/api/settings")),
+ ItExpr.IsAny()
+ )
+ .ReturnsAsync(new HttpResponseMessage
+ {
+ StatusCode = HttpStatusCode.OK,
+ Content = new StringContent(savedJson, Encoding.UTF8, "application/json")
+ });
+
+ var freshVm = new SettingsViewModel();
+ await freshVm.LoadSettingsAsync("http://127.0.0.1:5246", http);
+
+ Assert.Contains(freshVm.AllPresets, p => p.Name == "Synchronized Video Preset" && p.Id == "custom-123");
+ }
}
diff --git a/LocalLLMServerManager.Tests/StudioControlInstantiationTests.cs b/LocalLLMServerManager.Tests/StudioControlInstantiationTests.cs
new file mode 100644
index 0000000..7de3ea6
--- /dev/null
+++ b/LocalLLMServerManager.Tests/StudioControlInstantiationTests.cs
@@ -0,0 +1,79 @@
+using System.Collections.Generic;
+using Avalonia.Headless.XUnit;
+using LocalLLMServerManager.Shared.Models;
+using LocalLLMServerManager.Shared.Views.Controls;
+using Xunit;
+
+namespace LocalLLMServerManager.Tests;
+
+public class StudioControlInstantiationTests
+{
+ [Fact]
+ public void StudioPresetBarControl_InstantiatesAndSetsProperties()
+ {
+ var control = new StudioPresetBarControl();
+ Assert.NotNull(control);
+
+ var presets = new List
+ {
+ new StudioPreset { Name = "Test 1", Modality = StudioModality.Video }
+ };
+
+ control.Presets = presets;
+ control.SelectedPreset = presets[0];
+ control.StarterPrompts = presets;
+ control.IsCustomPreset = true;
+
+ Assert.Equal(presets, control.Presets);
+ Assert.Equal("Test 1", control.SelectedPreset?.Name);
+ Assert.True(control.IsCustomPreset);
+ }
+
+ [Fact]
+ public void GenerationStageTrackerControl_InstantiatesAndToggles()
+ {
+ var control = new GenerationStageTrackerControl();
+ Assert.NotNull(control);
+
+ Assert.Equal(0, control.CurrentStage);
+ Assert.False(control.IsLogsExpanded);
+ Assert.Equal("📜 Show Live Logs", control.LogsButtonText);
+
+ control.CurrentStage = 2;
+ control.Stage2Status = "Step 10/20";
+ control.ProgressValue = 50.0;
+ control.IsLogsExpanded = true;
+ control.LogsText = "Allocating tensors...";
+
+ Assert.Equal(2, control.CurrentStage);
+ Assert.Equal("Step 10/20", control.Stage2Status);
+ Assert.Equal(50.0, control.ProgressValue);
+ Assert.True(control.IsLogsExpanded);
+ Assert.Equal("📜 Hide Live Logs", control.LogsButtonText);
+ Assert.Equal("Allocating tensors...", control.LogsText);
+ }
+
+ [Fact]
+ public void TestFlightModalControl_InstantiatesAndSetsDefaults()
+ {
+ var control = new TestFlightModalControl();
+ Assert.NotNull(control);
+
+ Assert.Equal(StudioModality.Video, control.SelectedModality);
+ Assert.True(control.IsEngineOnline);
+ Assert.True(control.IsVramClear);
+ Assert.False(control.IsRunning);
+ Assert.False(control.IsSuccess);
+ Assert.False(control.HasError);
+
+ control.SelectedModality = StudioModality.Image;
+ control.StatusMessage = "Running image pass...";
+ control.ProgressValue = 75.0;
+ control.ErrorMessage = "CUDA OOM simulated";
+
+ Assert.Equal(StudioModality.Image, control.SelectedModality);
+ Assert.Equal("Running image pass...", control.StatusMessage);
+ Assert.Equal(75.0, control.ProgressValue);
+ Assert.True(control.HasError);
+ }
+}
diff --git a/LocalLLMServerManager.Tests/StudioIntegrationTests.cs b/LocalLLMServerManager.Tests/StudioIntegrationTests.cs
new file mode 100644
index 0000000..b6709b5
--- /dev/null
+++ b/LocalLLMServerManager.Tests/StudioIntegrationTests.cs
@@ -0,0 +1,174 @@
+using System.Collections.Generic;
+using System.Linq;
+using System.Threading.Tasks;
+using LocalLLMServerManager.Shared.Interfaces;
+using LocalLLMServerManager.Shared.Models;
+using LocalLLMServerManager.Shared.Services;
+using LocalLLMServerManager.Shared.ViewModels;
+using Xunit;
+
+namespace LocalLLMServerManager.Tests;
+
+public class StudioIntegrationTests
+{
+ private MainViewModel CreateMainViewModel()
+ {
+ MainViewModel.EnableAutomaticPolling = false;
+ var vm = new MainViewModel();
+ return vm;
+ }
+
+ [Fact]
+ public void VideoPresets_InitializedWithBuiltIns()
+ {
+ var vm = CreateMainViewModel();
+ Assert.NotEmpty(vm.VideoPresets);
+ Assert.Contains(vm.VideoPresets, p => p.Name.Contains("480p"));
+ Assert.Contains(vm.VideoPresets, p => p.Name.Contains("720p"));
+ }
+
+ [Fact]
+ public void SelectVideoPreset_UpdatesResolutionFrameCountAndCalculatesFit()
+ {
+ var vm = CreateMainViewModel();
+ var preset720p = vm.VideoPresets.First(p => p.Name.Contains("720p"));
+
+ vm.SelectVideoPresetCommand.Execute(preset720p);
+
+ Assert.Equal("1280x720", vm.VideoResolution);
+ Assert.Equal(preset720p.FrameCount, vm.VideoFrameCount);
+ Assert.Equal(preset720p.SamplePrompt, vm.VideoPrompt);
+ Assert.NotNull(vm.VideoHardwareFit);
+ Assert.True(vm.VideoHardwareFit.EstimatedVramMb > 0);
+ }
+
+ [Fact]
+ public void ImagePresets_InitializedWithBuiltIns_AndSelectionWorks()
+ {
+ var vm = CreateMainViewModel();
+ Assert.NotEmpty(vm.ImagePresets);
+
+ var landscapePreset = vm.ImagePresets.First(p => p.Name.Contains("Landscape"));
+ vm.SelectImagePresetCommand.Execute(landscapePreset);
+
+ Assert.Equal("1344x768", vm.ImageResolution);
+ Assert.Equal(1344, vm.ImageWidth);
+ Assert.Equal(768, vm.ImageHeight);
+ Assert.NotNull(vm.ImageHardwareFit);
+ Assert.True(vm.ImageHardwareFit.EstimatedVramMb > 0);
+ }
+
+ [Fact]
+ public void ApplyStarterChip_SetsPromptAndPreset()
+ {
+ var vm = CreateMainViewModel();
+ var preset = vm.VideoPresets.First();
+
+ vm.ApplyStarterChipCommand.Execute(preset);
+
+ Assert.Equal(preset.SamplePrompt, vm.VideoPrompt);
+ Assert.Equal(preset, vm.SelectedVideoPreset);
+ }
+
+ [Fact]
+ public void SaveAndDuplicateAndDeleteVideoPreset_ModifiesCollection()
+ {
+ var vm = CreateMainViewModel();
+ int initialCount = vm.VideoPresets.Count;
+
+ vm.VideoResolution = "1920x1080";
+ vm.VideoFrameCount = 64;
+ vm.SaveCurrentAsVideoPresetCommand.Execute("Epic Custom Cinematic");
+
+ Assert.Equal(initialCount + 1, vm.VideoPresets.Count);
+ var custom = vm.VideoPresets.First(p => p.Name == "Epic Custom Cinematic");
+ Assert.Equal(1920, custom.Width);
+ Assert.Equal(1080, custom.Height);
+ Assert.Equal(64, custom.FrameCount);
+ Assert.False(custom.IsBuiltIn);
+
+ // Duplicate
+ vm.DuplicateCurrentVideoPresetCommand.Execute(custom);
+ Assert.Equal(initialCount + 2, vm.VideoPresets.Count);
+ Assert.Contains(vm.VideoPresets, p => p.Name.Contains("Epic Custom Cinematic (Copy)"));
+
+ // Delete
+ vm.DeleteCurrentVideoPresetCommand.Execute(custom);
+ Assert.Equal(initialCount + 1, vm.VideoPresets.Count);
+ Assert.DoesNotContain(vm.VideoPresets, p => p.Name == "Epic Custom Cinematic");
+ }
+
+ [Fact]
+ public void StageTracking_PropertiesAndCommands_Work()
+ {
+ var vm = CreateMainViewModel();
+
+ Assert.Equal(0, vm.GenerationStage);
+ Assert.False(vm.IsLiveLogsExpanded);
+
+ vm.ToggleLiveLogsCommand.Execute(null);
+ Assert.True(vm.IsLiveLogsExpanded);
+
+ vm.ToggleLiveLogsCommand.Execute(null);
+ Assert.False(vm.IsLiveLogsExpanded);
+
+ vm.GenerationStage = 2;
+ vm.GenerationStageTitle = "Denoising";
+ vm.GenerationStageSubtext = "Step 15/30";
+ vm.LiveLogOutput = "Sampling latent tensors...";
+
+ Assert.Equal(2, vm.GenerationStage);
+ Assert.Equal("Denoising", vm.GenerationStageTitle);
+ Assert.Contains("Sampling", vm.LiveLogOutput);
+
+ vm.CancelGenerationCommand.Execute(null);
+ Assert.Equal(0, vm.GenerationStage);
+ Assert.False(vm.IsGeneratingVideo);
+ }
+
+ [Fact]
+ public async Task TestFlightModal_ExecutionFlow_Succeeds()
+ {
+ var vm = CreateMainViewModel();
+
+ Assert.False(vm.IsTestFlightOpen);
+ vm.OpenTestFlightCommand.Execute(null);
+ Assert.True(vm.IsTestFlightOpen);
+
+ vm.SelectTestFlightModalityCommand.Execute(StudioModality.Video);
+ Assert.Equal(StudioModality.Video, vm.TestFlightModality);
+ Assert.NotEmpty(vm.TestFlightStarterPrompts);
+
+ await vm.LaunchTestFlightCommand.ExecuteAsync(null);
+
+ Assert.True(vm.IsTestFlightSuccess);
+ Assert.False(vm.TestFlightHasError);
+ Assert.Contains("Succeeded", vm.TestFlightResultBannerText);
+
+ vm.CloseTestFlightCommand.Execute(null);
+ Assert.False(vm.IsTestFlightOpen);
+ }
+
+ [Fact]
+ public void AudioStudioViewModel_PresetsAndHardwareFit_Work()
+ {
+ var audioVm = new AudioStudioViewModel();
+ Assert.NotEmpty(audioVm.AudioPresets);
+
+ var narratorPreset = audioVm.AudioPresets.First(p => p.Name.Contains("Storyteller") || p.VoiceProfile == "af_heart");
+ audioVm.SelectAudioPresetCommand.Execute(narratorPreset);
+
+ Assert.Equal(narratorPreset.SamplePrompt, audioVm.Prompt);
+ Assert.NotNull(audioVm.AudioHardwareFit);
+ Assert.True(audioVm.AudioHardwareFit.EstimatedVramMb > 0);
+
+ audioVm.GenerationStage = 1;
+ audioVm.Stage1Status = "Loading Kokoro weights";
+ Assert.Equal(1, audioVm.GenerationStage);
+ Assert.Equal("Loading Kokoro weights", audioVm.Stage1Status);
+
+ audioVm.CancelGenerationCommand.Execute(null);
+ Assert.Equal(0, audioVm.GenerationStage);
+ Assert.False(audioVm.IsGenerating);
+ }
+}
diff --git a/LocalLLMServerManager.Tests/StudioPresetServiceTests.cs b/LocalLLMServerManager.Tests/StudioPresetServiceTests.cs
new file mode 100644
index 0000000..e2886e4
--- /dev/null
+++ b/LocalLLMServerManager.Tests/StudioPresetServiceTests.cs
@@ -0,0 +1,214 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using LocalLLMServerManager.Shared.Models;
+using LocalLLMServerManager.Shared.Services;
+using Xunit;
+
+namespace LocalLLMServerManager.Tests;
+
+public class StudioPresetServiceTests
+{
+ [Fact]
+ public void GetPresets_ReturnsBuiltInDefaults_ForVideoImageAudio()
+ {
+ var service = new StudioPresetService();
+ var videoPresets = service.GetPresets(StudioModality.Video);
+ var imagePresets = service.GetPresets(StudioModality.Image);
+ var audioPresets = service.GetPresets(StudioModality.Audio);
+
+ Assert.NotEmpty(videoPresets);
+ Assert.Contains(videoPresets, p => p.Name.Contains("480p", StringComparison.OrdinalIgnoreCase));
+ Assert.Contains(videoPresets, p => p.Name.Contains("720p", StringComparison.OrdinalIgnoreCase));
+ Assert.Contains(videoPresets, p => p.Name.Contains("Vertical Reel", StringComparison.OrdinalIgnoreCase));
+ Assert.Contains(videoPresets, p => p.Name.Contains("High-Fidelity", StringComparison.OrdinalIgnoreCase));
+
+ Assert.NotEmpty(imagePresets);
+ Assert.Contains(imagePresets, p => p.Name.Contains("Square", StringComparison.OrdinalIgnoreCase));
+ Assert.Contains(imagePresets, p => p.Name.Contains("Landscape", StringComparison.OrdinalIgnoreCase));
+ Assert.Contains(imagePresets, p => p.Name.Contains("Portrait", StringComparison.OrdinalIgnoreCase));
+
+ Assert.NotEmpty(audioPresets);
+ Assert.Contains(audioPresets, p => p.Name.Contains("Storyteller", StringComparison.OrdinalIgnoreCase));
+ Assert.Contains(audioPresets, p => p.Name.Contains("Broadcaster", StringComparison.OrdinalIgnoreCase));
+ Assert.Contains(audioPresets, p => p.Name.Contains("Ambient", StringComparison.OrdinalIgnoreCase));
+ Assert.Contains(audioPresets, p => p.Name.Contains("Song", StringComparison.OrdinalIgnoreCase));
+ }
+
+ [Fact]
+ public void GetAllPresets_ReturnsCombinedList()
+ {
+ var service = new StudioPresetService();
+ var all = service.GetAllPresets();
+ Assert.True(all.Count >= 11);
+ Assert.All(all, p => Assert.True(p.IsBuiltIn));
+ }
+
+ [Fact]
+ public void GetPresetById_FindsBuiltInAndCustomPresets()
+ {
+ var service = new StudioPresetService();
+ var builtIn = service.GetPresetById("builtin-video-480p");
+ Assert.NotNull(builtIn);
+ Assert.Equal("Quick 480p Preview", builtIn.Name);
+
+ var custom = new StudioPreset
+ {
+ Id = "custom-test-id",
+ Name = "My Custom Preset",
+ Modality = StudioModality.Image
+ };
+ service.SavePreset(custom);
+
+ var foundCustom = service.GetPresetById("custom-test-id");
+ Assert.NotNull(foundCustom);
+ Assert.Equal("My Custom Preset", foundCustom.Name);
+
+ var notFound = service.GetPresetById("non-existent-id");
+ Assert.Null(notFound);
+ }
+
+ [Fact]
+ public void SaveAndGetCustomPreset_WorksCorrectly()
+ {
+ var service = new StudioPresetService();
+ var custom = new StudioPreset
+ {
+ Name = "Custom 4K Video",
+ Modality = StudioModality.Video,
+ Width = 3840,
+ Height = 2160,
+ FrameCount = 60,
+ Fps = 30
+ };
+
+ service.SavePreset(custom);
+ var videoPresets = service.GetPresets(StudioModality.Video);
+
+ Assert.Contains(videoPresets, p => p.Name == "Custom 4K Video" && p.Width == 3840);
+ }
+
+ [Fact]
+ public void SavePreset_UpdatesExistingCustomPreset_WhenIdMatches()
+ {
+ var service = new StudioPresetService();
+ var custom = new StudioPreset
+ {
+ Id = "preset-to-update",
+ Name = "Initial Version",
+ Modality = StudioModality.Image,
+ Width = 512,
+ Height = 512
+ };
+ service.SavePreset(custom);
+
+ var updated = custom with { Name = "Updated Version", Width = 1024 };
+ service.SavePreset(updated);
+
+ var retrieved = service.GetPresetById("preset-to-update");
+ Assert.NotNull(retrieved);
+ Assert.Equal("Updated Version", retrieved.Name);
+ Assert.Equal(1024, retrieved.Width);
+ }
+
+ [Fact]
+ public void DeletePreset_RemovesCustom_DoesNotRemoveBuiltIn()
+ {
+ var service = new StudioPresetService();
+ var custom = new StudioPreset
+ {
+ Name = "Temporary Preset",
+ Modality = StudioModality.Image
+ };
+ service.SavePreset(custom);
+ Assert.Contains(service.GetPresets(StudioModality.Image), p => p.Name == "Temporary Preset");
+
+ var deleted = service.DeletePreset(custom.Id);
+ Assert.True(deleted);
+ Assert.DoesNotContain(service.GetPresets(StudioModality.Image), p => p.Name == "Temporary Preset");
+
+ var builtIn = service.GetPresets(StudioModality.Video).First(p => p.IsBuiltIn);
+ var deletedBuiltIn = service.DeletePreset(builtIn.Id);
+ Assert.False(deletedBuiltIn);
+
+ var nonExistentDeleted = service.DeletePreset("does-not-exist");
+ Assert.False(nonExistentDeleted);
+ }
+
+ [Fact]
+ public void DuplicatePreset_CreatesCopy_WithAppendedName()
+ {
+ var service = new StudioPresetService();
+ var builtIn = service.GetPresets(StudioModality.Video).First(p => p.IsBuiltIn);
+ var copy = service.DuplicatePreset(builtIn.Id);
+
+ Assert.NotNull(copy);
+ Assert.NotEqual(builtIn.Id, copy.Id);
+ Assert.Equal($"{builtIn.Name} (Copy)", copy.Name);
+ Assert.False(copy.IsBuiltIn);
+
+ var foundInPresets = service.GetPresets(StudioModality.Video);
+ Assert.Contains(foundInPresets, p => p.Id == copy.Id);
+ }
+
+ [Fact]
+ public void DuplicatePreset_ReturnsNull_WhenIdNotFound()
+ {
+ var service = new StudioPresetService();
+ var copy = service.DuplicatePreset("non-existent-preset-id");
+ Assert.Null(copy);
+ }
+
+ [Fact]
+ public void ExportAndImportJson_PreservesCustomPresets()
+ {
+ var service = new StudioPresetService();
+ service.SavePreset(new StudioPreset { Name = "ExportTest", Modality = StudioModality.Audio });
+ var json = service.ExportJson();
+
+ var newService = new StudioPresetService();
+ var success = newService.ImportJson(json);
+
+ Assert.True(success);
+ Assert.Contains(newService.GetPresets(StudioModality.Audio), p => p.Name == "ExportTest");
+ }
+
+ [Fact]
+ public void ImportJson_HandlesInvalidJsonGracefully()
+ {
+ var service = new StudioPresetService();
+ Assert.False(service.ImportJson(""));
+ Assert.False(service.ImportJson("{ not valid json }"));
+ }
+
+ [Fact]
+ public void ResetToDefaults_ClearsAllCustomPresets()
+ {
+ var service = new StudioPresetService();
+ service.SavePreset(new StudioPreset { Name = "Custom 1", Modality = StudioModality.Video });
+ service.SavePreset(new StudioPreset { Name = "Custom 2", Modality = StudioModality.Image });
+
+ Assert.Contains(service.GetPresets(StudioModality.Video), p => p.Name == "Custom 1");
+ Assert.Contains(service.GetPresets(StudioModality.Image), p => p.Name == "Custom 2");
+
+ service.ResetToDefaults();
+
+ Assert.DoesNotContain(service.GetPresets(StudioModality.Video), p => p.Name == "Custom 1");
+ Assert.DoesNotContain(service.GetPresets(StudioModality.Image), p => p.Name == "Custom 2");
+ }
+
+ [Fact]
+ public void Constructor_LoadsInitialCustomPresets()
+ {
+ var initialList = new List
+ {
+ new StudioPreset { Id = "init-1", Name = "Preloaded Custom", Modality = StudioModality.Audio, IsBuiltIn = false }
+ };
+
+ var service = new StudioPresetService(initialList);
+ var preset = service.GetPresetById("init-1");
+
+ Assert.NotNull(preset);
+ Assert.Equal("Preloaded Custom", preset.Name);
+ }
+}
diff --git a/LocalLLMServerManager.Tests/WasmAssetFreshnessTests.cs b/LocalLLMServerManager.Tests/WasmAssetFreshnessTests.cs
index 991b56a..152618a 100644
--- a/LocalLLMServerManager.Tests/WasmAssetFreshnessTests.cs
+++ b/LocalLLMServerManager.Tests/WasmAssetFreshnessTests.cs
@@ -52,7 +52,7 @@ public void MainJs_VersionStringMatchesCurrentVersion()
var mainJsPath = Path.Combine(root, "wwwroot", "main.js");
var webMainJsPath = Path.Combine(root, "LocalLLMServerManager.Web", "main.js");
- var expectedVersion = typeof(MainViewModel).Assembly.GetName().Version?.ToString(3) ?? "3.12.1";
+ var expectedVersion = typeof(MainViewModel).Assembly.GetName().Version?.ToString(3) ?? "3.13.0";
foreach (var path in new[] { mainJsPath, webMainJsPath })
{
diff --git a/LocalLLMServerManager.Web/LocalLLMServerManager.Web.csproj b/LocalLLMServerManager.Web/LocalLLMServerManager.Web.csproj
index 6f0bec7..c3051cb 100644
--- a/LocalLLMServerManager.Web/LocalLLMServerManager.Web.csproj
+++ b/LocalLLMServerManager.Web/LocalLLMServerManager.Web.csproj
@@ -4,7 +4,9 @@
net10.0
enable
enable
- 3.12.1
+ 3.13.0
+ 3.13.0.0
+ 3.13.0.0
main.js
Exe
true
diff --git a/LocalLLMServerManager.Web/main.js b/LocalLLMServerManager.Web/main.js
index 9740f0d..0403af8 100644
--- a/LocalLLMServerManager.Web/main.js
+++ b/LocalLLMServerManager.Web/main.js
@@ -5,7 +5,7 @@ if (!is_browser) {
throw new Error(`Expected to be running in a browser`);
}
-const APP_VERSION = "3.12.1";
+const APP_VERSION = "3.13.0";
globalThis.getOrigin = function () {
return window.location.origin;
diff --git a/LocalLLMServerManager.csproj b/LocalLLMServerManager.csproj
index cb698a5..60b5891 100644
--- a/LocalLLMServerManager.csproj
+++ b/LocalLLMServerManager.csproj
@@ -11,9 +11,9 @@
MINOR — new user-facing features (bump per feature PR)
PATCH — bug fixes, dependency updates, doc-only changes
-->
- 3.12.1
- 3.12.1.0
- 3.12.1.0
+ 3.13.0
+ 3.13.0.0
+ 3.13.0.0
Assets\app-icon.ico
true
diff --git a/Views/MainWindow.axaml b/Views/MainWindow.axaml
index e059e3b..0d7c60e 100644
--- a/Views/MainWindow.axaml
+++ b/Views/MainWindow.axaml
@@ -6,7 +6,7 @@
mc:Ignorable="d" d:DesignWidth="1280" d:DesignHeight="840"
x:Class="LocalLLMServerManager.Views.MainWindow"
Icon="avares://LocalLLMServerManager/Assets/app-icon.ico"
- Title="Local LLM Server Manager v3.12.1"
+ Title="Local LLM Server Manager v3.13.0"
Width="1280" Height="840"
MinWidth="1024" MinHeight="700"
WindowStartupLocation="CenterScreen"
diff --git a/docs/superpowers/plans/2026-09-07-studio-presets-test-flight.md b/docs/superpowers/plans/2026-09-07-studio-presets-test-flight.md
new file mode 100644
index 0000000..aa8e831
--- /dev/null
+++ b/docs/superpowers/plans/2026-09-07-studio-presets-test-flight.md
@@ -0,0 +1,291 @@
+# Studio Presets, Test Flight & Rich Stage Feedback Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Provide an intuitive, foolproof studio experience for Image, Video, and Audio/TTS generation featuring curated & custom presets, real-time hardware fit pre-flight checks, a guided diagnostic test flight modal, and rich 4-stage generation progress with collapsible engine logs.
+
+**Architecture:** Implement a modular `StudioPresetService` and hardware fit estimator in `LocalLLMServerManager.Shared`. Build reusable Avalonia controls (`StudioPresetBarControl`, `GenerationStageTrackerControl`, `TestFlightModalControl`) shared across desktop and WASM web clients. Wire them into `EngineStudioTabControl` and `SettingsTabControl`.
+
+**Tech Stack:** .NET 10 / C#, Avalonia UI (Desktop & Web WASM), CommunityToolkit.Mvvm, xUnit for unit tests, TypeScript / ESLint for tooling validation.
+
+## Global Constraints
+- Target Framework: .NET 10.0 (`net10.0`).
+- Cross-platform: Must compile and run identically on Avalonia Desktop and Browser WASM.
+- Quality Gates: Always run `npm run lint`, `npx tsc --noEmit`, and `dotnet test` before marking work complete.
+- Minor version bump required upon completion.
+
+---
+
+### Task 1: Core Models & StudioPresetService with Unit Tests
+
+**Files:**
+- Create: `LocalLLMServerManager.Shared/Models/StudioPresetModels.cs`
+- Create: `LocalLLMServerManager.Shared/Interfaces/IStudioPresetService.cs`
+- Create: `LocalLLMServerManager.Shared/Services/StudioPresetService.cs`
+- Modify: `LocalLLMServerManager.Shared/Models/AppSettings.cs`
+- Create: `LocalLLMServerManager.Tests/StudioPresetServiceTests.cs`
+
+**Interfaces:**
+- Produces:
+ - `StudioModality` enum (`Image`, `Video`, `Audio`)
+ - `StudioPreset` record (`Id`, `Name`, `Description`, `Modality`, `WorkflowOrEngine`, `Width`, `Height`, `FrameCount`, `Fps`, `DurationSeconds`, `VoiceProfile`, `SamplePrompt`, `NegativePrompt`, `IsBuiltIn`)
+ - `IStudioPresetService` (`GetPresets(StudioModality modality)`, `SavePreset(StudioPreset preset)`, `DeletePreset(string id)`, `DuplicatePreset(string id)`, `ExportJson()`, `ImportJson(string json)`, `ResetToDefaults()`)
+
+- [ ] **Step 1: Write the failing unit tests for StudioPresetService**
+
+Create `LocalLLMServerManager.Tests/StudioPresetServiceTests.cs`:
+```csharp
+using System.Linq;
+using LocalLLMServerManager.Shared.Models;
+using LocalLLMServerManager.Shared.Services;
+using Xunit;
+
+namespace LocalLLMServerManager.Tests;
+
+public class StudioPresetServiceTests
+{
+ [Fact]
+ public void GetPresets_ReturnsBuiltInDefaults_ForVideoImageAudio()
+ {
+ var service = new StudioPresetService();
+ var videoPresets = service.GetPresets(StudioModality.Video);
+ var imagePresets = service.GetPresets(StudioModality.Image);
+ var audioPresets = service.GetPresets(StudioModality.Audio);
+
+ Assert.NotEmpty(videoPresets);
+ Assert.Contains(videoPresets, p => p.Name.Contains("480p", System.StringComparison.OrdinalIgnoreCase));
+ Assert.NotEmpty(imagePresets);
+ Assert.NotEmpty(audioPresets);
+ }
+
+ [Fact]
+ public void SaveAndGetCustomPreset_WorksCorrectly()
+ {
+ var service = new StudioPresetService();
+ var custom = new StudioPreset
+ {
+ Name = "Custom 4K Video",
+ Modality = StudioModality.Video,
+ Width = 3840,
+ Height = 2160,
+ FrameCount = 60,
+ Fps = 30
+ };
+
+ service.SavePreset(custom);
+ var videoPresets = service.GetPresets(StudioModality.Video);
+
+ Assert.Contains(videoPresets, p => p.Name == "Custom 4K Video" && p.Width == 3840);
+ }
+
+ [Fact]
+ public void DeletePreset_RemovesCustom_DoesNotRemoveBuiltIn()
+ {
+ var service = new StudioPresetService();
+ var custom = new StudioPreset
+ {
+ Name = "Temporary Preset",
+ Modality = StudioModality.Image
+ };
+ service.SavePreset(custom);
+ Assert.Contains(service.GetPresets(StudioModality.Image), p => p.Name == "Temporary Preset");
+
+ var deleted = service.DeletePreset(custom.Id);
+ Assert.True(deleted);
+ Assert.DoesNotContain(service.GetPresets(StudioModality.Image), p => p.Name == "Temporary Preset");
+
+ var builtIn = service.GetPresets(StudioModality.Video).First(p => p.IsBuiltIn);
+ var deletedBuiltIn = service.DeletePreset(builtIn.Id);
+ Assert.False(deletedBuiltIn);
+ }
+
+ [Fact]
+ public void ExportAndImportJson_PreservesCustomPresets()
+ {
+ var service = new StudioPresetService();
+ service.SavePreset(new StudioPreset { Name = "ExportTest", Modality = StudioModality.Audio });
+ var json = service.ExportJson();
+
+ var newService = new StudioPresetService();
+ newService.ImportJson(json);
+
+ Assert.Contains(newService.GetPresets(StudioModality.Audio), p => p.Name == "ExportTest");
+ }
+}
+```
+
+- [ ] **Step 2: Run test to verify it fails to compile/run**
+
+Run: `dotnet test LocalLLMServerManager.Tests/LocalLLMServerManager.Tests.csproj --filter "FullyQualifiedName~StudioPresetServiceTests"`
+
+- [ ] **Step 3: Implement StudioPresetModels, IStudioPresetService, and StudioPresetService**
+
+Implement `StudioPresetModels.cs`, `IStudioPresetService.cs`, `StudioPresetService.cs`, and update `AppSettings.cs` with `List CustomPresets { get; set; } = new();`.
+
+- [ ] **Step 4: Run test to verify it passes**
+
+Run: `dotnet test LocalLLMServerManager.Tests/LocalLLMServerManager.Tests.csproj --filter "FullyQualifiedName~StudioPresetServiceTests"`
+
+- [ ] **Step 5: Commit changes**
+
+```bash
+git add LocalLLMServerManager.Shared/Models/StudioPresetModels.cs LocalLLMServerManager.Shared/Interfaces/IStudioPresetService.cs LocalLLMServerManager.Shared/Services/StudioPresetService.cs LocalLLMServerManager.Shared/Models/AppSettings.cs LocalLLMServerManager.Tests/StudioPresetServiceTests.cs
+git commit -m "feat: add StudioPresetService and data models with tests"
+```
+
+---
+
+### Task 2: Hardware Fit Pre-Flight Estimation
+
+**Files:**
+- Modify: `LocalLLMServerManager.Shared/Interfaces/ICanIRunItService.cs`
+- Modify: `LocalLLMServerManager.Shared/Services/CanIRunItService.cs`
+- Modify: `LocalLLMServerManager.Tests/CanIRunItServiceTests.cs`
+
+**Interfaces:**
+- Produces:
+ - `StudioHardwareFit EstimateStudioHardwareFit(StudioModality modality, int width, int height, int frameCount, string workflow, double freeVramMb, double totalVramMb)`
+ - `StudioHardwareFit` record (`FitBadge`, `EstimatedVramMb`, `StatusText`, `RecommendedPresetName`, `RequiresLlmUnload`)
+
+- [ ] **Step 1: Write failing unit test for Studio Hardware Fit estimation**
+
+Add tests to `LocalLLMServerManager.Tests/CanIRunItServiceTests.cs`:
+```csharp
+[Fact]
+public void EstimateStudioHardwareFit_CalculatesAccurately()
+{
+ var service = new CanIRunItService();
+ // 480p Video on 16GB GPU with 12GB Free -> Ready
+ var fit = service.EstimateStudioHardwareFit(StudioModality.Video, 832, 480, 48, "wan2.2", 12000, 16000);
+ Assert.Equal("Ready", fit.StatusText);
+ Assert.False(fit.RequiresLlmUnload);
+
+ // 720p Video with only 4GB Free on 12GB GPU -> Requires LLM unload
+ var fitTight = service.EstimateStudioHardwareFit(StudioModality.Video, 1280, 720, 48, "wan2.2", 4000, 12000);
+ Assert.True(fitTight.RequiresLlmUnload);
+}
+```
+
+- [ ] **Step 2: Run test to verify it fails**
+
+Run: `dotnet test LocalLLMServerManager.Tests/LocalLLMServerManager.Tests.csproj --filter "FullyQualifiedName~EstimateStudioHardwareFit"`
+
+- [ ] **Step 3: Implement EstimateStudioHardwareFit in CanIRunItService**
+
+Implement heuristic calculation logic based on pixel count, frame count, and modality.
+
+- [ ] **Step 4: Run test to verify it passes**
+
+Run: `dotnet test LocalLLMServerManager.Tests/LocalLLMServerManager.Tests.csproj --filter "FullyQualifiedName~EstimateStudioHardwareFit"`
+
+- [ ] **Step 5: Commit changes**
+
+```bash
+git add LocalLLMServerManager.Shared/Interfaces/ICanIRunItService.cs LocalLLMServerManager.Shared/Services/CanIRunItService.cs LocalLLMServerManager.Tests/CanIRunItServiceTests.cs
+git commit -m "feat: implement pre-flight studio hardware fit estimation in CanIRunItService"
+```
+
+---
+
+### Task 3: Reusable Avalonia UI Controls
+
+**Files:**
+- Create: `LocalLLMServerManager.Shared/Views/Controls/StudioPresetBarControl.axaml` + `.cs`
+- Create: `LocalLLMServerManager.Shared/Views/Controls/GenerationStageTrackerControl.axaml` + `.cs`
+- Create: `LocalLLMServerManager.Shared/Views/Controls/TestFlightModalControl.axaml` + `.cs`
+
+**Interfaces:**
+- Produces:
+ - ``: Preset selector, Quick Save dialog, starter prompt chips.
+ - ``: 4-stage pipeline stepper, elapsed timer, live engine logs drawer, cancel button.
+ - ``: Full diagnostic modal for 1-click test runs.
+
+- [ ] **Step 1: Create StudioPresetBarControl**
+- [ ] **Step 2: Create GenerationStageTrackerControl**
+- [ ] **Step 3: Create TestFlightModalControl**
+- [ ] **Step 4: Compile and verify Avalonia XAML bindings**
+
+Run: `dotnet build LocalLLMServerManager.Shared/LocalLLMServerManager.Shared.csproj`
+
+- [ ] **Step 5: Commit changes**
+
+```bash
+git add LocalLLMServerManager.Shared/Views/Controls/StudioPresetBarControl.axaml* LocalLLMServerManager.Shared/Views/Controls/GenerationStageTrackerControl.axaml* LocalLLMServerManager.Shared/Views/Controls/TestFlightModalControl.axaml*
+git commit -m "feat: add reusable StudioPresetBarControl, GenerationStageTrackerControl, and TestFlightModalControl"
+```
+
+---
+
+### Task 4: Integrate Studio Presets, Hardware Fit, & Stage Tracking into Studio ViewModels and UI
+
+**Files:**
+- Modify: `LocalLLMServerManager.Shared/ViewModels/MainViewModel.cs`
+- Modify: `LocalLLMServerManager.Shared/ViewModels/AudioStudioViewModel.cs`
+- Modify: `LocalLLMServerManager.Shared/Views/Controls/EngineStudioTabControl.axaml`
+- Create: `LocalLLMServerManager.Tests/StudioIntegrationTests.cs`
+
+- [ ] **Step 1: Write integration tests for Studio ViewModel preset switching and stage tracking**
+- [ ] **Step 2: Update MainViewModel & AudioStudioViewModel with StudioPresetService and Stage State Tracker**
+- [ ] **Step 3: Wire up EngineStudioTabControl.axaml with PresetBar, Pre-Flight Hardware Badges, StageTracker, and Test Flight modal button**
+- [ ] **Step 4: Run integration tests**
+
+Run: `dotnet test LocalLLMServerManager.Tests/LocalLLMServerManager.Tests.csproj --filter "FullyQualifiedName~StudioIntegrationTests"`
+
+- [ ] **Step 5: Commit changes**
+
+```bash
+git add LocalLLMServerManager.Shared/ViewModels/MainViewModel.cs LocalLLMServerManager.Shared/ViewModels/AudioStudioViewModel.cs LocalLLMServerManager.Shared/Views/Controls/EngineStudioTabControl.axaml LocalLLMServerManager.Tests/StudioIntegrationTests.cs
+git commit -m "feat: wire studio presets, hardware fit badges, and stage tracker into EngineStudioTabControl"
+```
+
+---
+
+### Task 5: Centralized Presets Manager in Settings View
+
+**Files:**
+- Modify: `LocalLLMServerManager.Shared/ViewModels/SettingsViewModel.cs`
+- Modify: `LocalLLMServerManager.Shared/Views/Controls/SettingsTabControl.axaml`
+
+- [ ] **Step 1: Add preset management commands & collections to SettingsViewModel**
+ - `ObservableCollection AllPresets`
+ - `CreatePresetCommand`, `EditPresetCommand`, `DeletePresetCommand`, `DuplicatePresetCommand`, `ExportPresetsCommand`, `ImportPresetsCommand`, `ResetPresetsToDefaultCommand`
+- [ ] **Step 2: Add "Studio Presets Manager" Card in SettingsTabControl.axaml**
+ - Modality filter tabs (`All`, `🎬 Video`, `🎨 Image`, `🎵 Audio/TTS`)
+ - Preset data items control with edit/delete/duplicate action buttons
+ - JSON import/export and reset defaults buttons
+- [ ] **Step 3: Test and compile settings preset manager**
+
+Run: `dotnet build LocalLLMServerManager.Shared/LocalLLMServerManager.Shared.csproj`
+
+- [ ] **Step 4: Commit changes**
+
+```bash
+git add LocalLLMServerManager.Shared/ViewModels/SettingsViewModel.cs LocalLLMServerManager.Shared/Views/Controls/SettingsTabControl.axaml
+git commit -m "feat: add centralized studio presets manager in Settings view"
+```
+
+---
+
+### Task 6: Verification, Version Bump, and Branch/PR Preparation
+
+**Files:**
+- Modify: `LocalLLMServerManager.Shared/ViewModels/MainViewModel.cs` (app version bump)
+- Modify: `.csproj` files if applicable
+
+- [ ] **Step 1: Run complete test suite**
+
+Run: `dotnet test LocalLLMServerManager.Tests/LocalLLMServerManager.Tests.csproj`
+
+- [ ] **Step 2: Run linters & typecheck**
+
+Run: `npm run lint` and `npx tsc --noEmit`
+
+- [ ] **Step 3: Minor version bump**
+
+Bump version from `3.12.1` to `3.13.0` across the application.
+
+- [ ] **Step 4: Commit and verify git log**
+
+```bash
+git commit -am "chore: bump version to 3.13.0 and finalize studio presets & test flight release"
+```
diff --git a/docs/superpowers/specs/2026-09-07-studio-presets-test-flight-design.md b/docs/superpowers/specs/2026-09-07-studio-presets-test-flight-design.md
new file mode 100644
index 0000000..712957f
--- /dev/null
+++ b/docs/superpowers/specs/2026-09-07-studio-presets-test-flight-design.md
@@ -0,0 +1,142 @@
+# Studio Presets, Test Flight & Rich Stage Feedback Design Specification
+
+## Overview & Goals
+The goal of this feature set is to demystify local AI generation (specifically Video, Image, and Audio/TTS synthesis) and eliminate user friction and fear of complicated parameters, Out-Of-Memory (OOM) errors, or broken workflows.
+
+This is accomplished by introducing:
+1. **Curated & Custom Studio Presets:** Pre-tuned one-click configurations for common standard resolutions, frame rates, and voice profiles across Video, Image, and Audio/TTS, with complete custom preset creation/editing both inline and in the Settings tab.
+2. **"Can I Run It" Pre-Flight & Live Hardware Fit Indicator:** Real-time VRAM verification badge directly on generation tabs before the user clicks Generate, complete with automated safety guardrails (VRAM orchestrator LLM paging).
+3. **Interactive Test Flight & Starter Prompt Chips:** 1-click starter chips and a dedicated step-by-step diagnostic test flight wizard to guarantee a successful "Hello World" generation out of the box.
+4. **Rich Stage Feedback & Live Pipeline Tracker:** A 4-stage visual progress pipeline (`VRAM & Model Load` ➔ `Denoising/Sampling` ➔ `Encoding & Assembly` ➔ `Ready`) with elapsed timers, live memory monitoring, and collapsible real-time engine logs.
+
+---
+
+## 1. Data Models & Service Architecture
+
+### 1.1 `StudioPreset` Model (`LocalLLMServerManager.Shared/Models/StudioPresetModels.cs`)
+```csharp
+namespace LocalLLMServerManager.Shared.Models;
+
+public enum StudioModality
+{
+ Image,
+ Video,
+ Audio
+}
+
+public record StudioPreset
+{
+ public string Id { get; init; } = Guid.NewGuid().ToString("N");
+ public string Name { get; init; } = "";
+ public string Description { get; init; } = "";
+ public StudioModality Modality { get; init; } = StudioModality.Video;
+ public string WorkflowOrEngine { get; init; } = "";
+ public int Width { get; init; } = 832;
+ public int Height { get; init; } = 480;
+ public int FrameCount { get; init; } = 48;
+ public int Fps { get; init; } = 16;
+ public int DurationSeconds { get; init; } = 3;
+ public string VoiceProfile { get; init; } = "";
+ public string SamplePrompt { get; init; } = "";
+ public string NegativePrompt { get; init; } = "";
+ public bool IsBuiltIn { get; init; } = false;
+}
+```
+
+### 1.2 Default Built-in Presets
+* **Video Generation:**
+ * `quick_480p`: "⚡ Quick 480p Preview" (832x480, 32 frames, 16 fps, ~2s duration)
+ * `cinematic_720p`: "🎬 Cinematic HD (720p)" (1280x720, 48 frames, 16 fps, ~3s duration)
+ * `vertical_reel_9_16`: "📱 Vertical Reel (9:16)" (480x832, 48 frames, 16 fps)
+ * `high_fps_master`: "🌟 High-Fidelity Master" (1024x576, 64 frames, 24 fps)
+* **Image Generation:**
+ * `image_square_1024`: "🖼️ Standard Square (1024x1024)"
+ * `image_landscape_wide`: "🌄 Landscape Wallpaper (1344x768)"
+ * `image_portrait_photo`: "📸 Portrait Photo (768x1152)"
+* **Audio / TTS Generation:**
+ * `tts_narrator`: "🎙️ Natural Storyteller" (`af_heart`, clean speech)
+ * `tts_radio`: "📻 Energetic Broadcaster" (`am_michael`, vibrant inflection)
+ * `audio_sfx_ambient`: "🌧️ Ambient Soundscape" (Stable Audio Open, 30-sec loop)
+ * `audio_song_yue`: "🎸 Full Song Generator" (YuE lyrics-to-music)
+
+### 1.3 `StudioPresetService` (`LocalLLMServerManager.Shared/Services/StudioPresetService.cs`)
+* Manages the active list of presets (merging default built-ins with user custom presets stored in `AppSettings.CustomPresets`).
+* Provides CRUD operations: `GetPresets(modality)`, `SaveCustomPreset(preset)`, `DeleteCustomPreset(id)`, `DuplicatePreset(id)`, `ExportPresetsJson()`, `ImportPresetsJson(json)`, and `ResetToDefaults()`.
+* Fully available on both Desktop and WASM builds.
+
+---
+
+## 2. Pre-Flight Hardware Fit & "Can I Run It" Integration
+
+### 2.1 Live VRAM Estimation
+* The Studio view subscribes to parameter changes (resolution, frame count, workflow).
+* Calls `CanIRunItService.CalculateRequiredVram(modality, resolution, frameCount)` to compute estimated VRAM load.
+* Queries `TelemetryService` for total and free VRAM.
+
+### 2.2 Status Badging
+* 🟢 **Optimal (`Ready`):** Estimated VRAM fits comfortably in free GPU memory.
+* 🟡 **Tight (`LLM Auto-Unload`):** Estimated VRAM fits in total GPU memory, but requires unloading active Ollama models. Handled safely by `VramOrchestrator`.
+* 🔴 **Exceeds GPU Limit:** Estimated VRAM exceeds total physical GPU VRAM. Provides a 1-click suggestion button (e.g., `⚡ Switch to Quick 480p Preview`).
+
+---
+
+## 3. Test Flight Experience & Starter Prompt Chips
+
+### 3.1 Inline Starter Prompt Chips
+* Placed above prompt boxes for rapid, verified 1-click loading.
+* Video Chips:
+ * `[🐕 Golden Retriever Beach]` ➔ Fills prompt + auto-selects `Quick 480p Preview` preset.
+ * `[🌆 Cyberpunk Rain 720p]` ➔ Fills prompt + auto-selects `Cinematic HD` preset.
+ * `[☕ Cozy Cafe Steam]` ➔ Fills slow atmospheric motion prompt.
+ * `[🚀 Space Nebula Flyby]` ➔ Fills dynamic sci-fi motion prompt.
+* Audio Chips:
+ * `[🎙️ Natural Storyteller Sample]` ➔ Fills sample script + selects `af_heart` voice.
+ * `[🌧️ Rainy Cyberpunk Ambience]` ➔ Fills ambient SFX prompt.
+ * `[🎵 Retro Synthwave Melody]` ➔ Fills electronic synth song prompt.
+
+### 3.2 "🚀 Run System Test Flight" Modal Dialog
+* Accessible from the Studio header via ``.
+* **Step 1:** Modality selection (Video / Image / Audio) + prompt picker.
+* **Step 2:** Pre-flight sanity checks (`✓ Engine Active`, `✓ VRAM Clearance`).
+* **Step 3:** 1-Click test run execution.
+* **Step 4:** Live stage progress tracking.
+* **Step 5:** Preview output display with confirmation badge: `🎉 Test Flight Succeeded! Your system is verified and ready.`
+
+---
+
+## 4. Rich Stage Feedback & Live Pipeline Tracker
+
+### 4.1 4-Stage Visual Progress Pipeline
+* **Stage 1 (VRAM & Weights):** Models loading into GPU, LLM memory unallocated.
+* **Stage 2 (Sampling / Denoising):** Iterative generation and frame progress percentage.
+* **Stage 3 (Encoding & Assembly):** VAE decode and container export (MP4/WAV).
+* **Stage 4 (Ready & Complete):** Result ready for preview and instant playback.
+
+### 4.2 Live Metrics & Drawer
+* **Timer:** Live elapsed execution time and estimated completion duration (`⏱️ 0:14s elapsed • Est. ~0:25s total`).
+* **Toggleable Live Engine Log:** Collapsible drawer showing real-time WebSocket events and engine logs for troubleshooting.
+* **Cancel / Abort Action:** Safely cancels queued or executing generation jobs.
+
+---
+
+## 5. UI Controls & Layout
+
+### 5.1 Reusable UI Controls
+* `LocalLLMServerManager.Shared/Views/Controls/StudioPresetBarControl.axaml`: Preset dropdown + Save/Edit/Delete buttons + Starter chips.
+* `LocalLLMServerManager.Shared/Views/Controls/GenerationStageTrackerControl.axaml`: 4-stage pipeline stepper + elapsed timer + expandable log.
+* `LocalLLMServerManager.Shared/Views/Controls/TestFlightModalControl.axaml`: Multi-step diagnostic test flight modal.
+
+### 5.2 Settings View Enhancement
+* In `SettingsTabControl.axaml`: Add the **"🎨 Studio Presets Manager"** card containing the filterable preset table, edit dialogs, JSON export/import, and factory reset actions.
+
+---
+
+## 6. Verification & Quality Gates
+1. **Unit & Logic Tests:**
+ * Test `StudioPresetService` CRUD operations, default seeds, and serialization.
+ * Test VRAM pre-flight calculation against different resolutions and presets.
+2. **Avalonia UI & Compilation:**
+ * Ensure clean compile of both desktop and WASM projects (`dotnet build`).
+ * Run typecheck and linter: `npm run lint` and `npx tsc --noEmit`.
+3. **Minor Version Bump:**
+ * Update version in `.csproj` files / `MainViewModel.cs` to next minor version.
diff --git a/scripts/build_release.ps1 b/scripts/build_release.ps1
index c8e4fb4..55ffe39 100644
--- a/scripts/build_release.ps1
+++ b/scripts/build_release.ps1
@@ -1,8 +1,8 @@
-# LocalLLMServerManager v3.12.1 — Release Build & Package Script
+# LocalLLMServerManager v3.13.0 — Release Build & Package Script
# Builds Win-x64 Desktop exe, Linux-x64 SingleFile daemon, and Browser-WASM distribution.
param(
- [string]$Version = "3.12.1"
+ [string]$Version = "3.13.0"
)
$ErrorActionPreference = "Stop"
diff --git a/scripts/installer.iss b/scripts/installer.iss
index f4a7df1..c9d7aee 100644
--- a/scripts/installer.iss
+++ b/scripts/installer.iss
@@ -1,7 +1,7 @@
-; Script generated for Inno Setup - LocalLLMServerManager v3.12.1
+; Script generated for Inno Setup - LocalLLMServerManager v3.13.0
; Windows Inno Setup build configuration with automated Firewall configuration, Windows Service management, and system tray startup
#define MyAppName "Local LLM Server Manager"
-#define MyAppVersion "3.12.1"
+#define MyAppVersion "3.13.0"
#define MyAppPublisher "LocalLLMServerManager Team"
#define MyAppURL "https://github.com/spelech/LocalLLMServerManager"
#define MyAppExeName "LocalLLMServerManager.exe"
diff --git a/wwwroot/index.html b/wwwroot/index.html
index 40cddb5..a23d8fd 100644
--- a/wwwroot/index.html
+++ b/wwwroot/index.html
@@ -44,7 +44,7 @@
-
+