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