Initial Dansori EQ workspace
@@ -0,0 +1,24 @@
|
||||
// PRE-GENERATED (design session). Builds the active IAiEqProvider from AppSettings + stored key.
|
||||
using DansoriEQ.Core.Ai;
|
||||
using DansoriEQ.Core.Security;
|
||||
|
||||
namespace DansoriEQ.App.Ai;
|
||||
|
||||
public static class AiProviderFactory
|
||||
{
|
||||
/// <summary>Returns the configured provider, or null if a cloud key is missing.</summary>
|
||||
public static IAiEqProvider? Create(ISecretStore store)
|
||||
{
|
||||
var s = AppSettings.Load();
|
||||
var provider = s.AiProvider ?? "claude";
|
||||
var model = s.AiModel;
|
||||
|
||||
return provider switch
|
||||
{
|
||||
"openai" => store.Load(ProviderKeys.OpenAi) is { Length: > 0 } ok ? new OpenAiProvider(ok, model) : null,
|
||||
"gemini" => store.Load(ProviderKeys.Gemini) is { Length: > 0 } gk ? new GeminiProvider(gk, model) : null,
|
||||
"ollama" => new OllamaProvider(model ?? "phi3.5"),
|
||||
_ => store.Load(ProviderKeys.Claude) is { Length: > 0 } ck ? new ClaudeClient(ck, model) : null
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
// PRE-GENERATED (design session). Fetches the models available to the user's ACCOUNT
|
||||
// from each cloud provider's list-models API, using the stored API key. Untested here.
|
||||
// Returns [] on failure (caller falls back to a static list).
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Net.Http;
|
||||
using System.Text.Json;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace DansoriEQ.App.Ai;
|
||||
|
||||
public static class CloudModelLister
|
||||
{
|
||||
private static readonly HttpClient Http = new() { Timeout = TimeSpan.FromSeconds(20) };
|
||||
|
||||
public static Task<List<string>> ListAsync(string provider, string apiKey) => provider switch
|
||||
{
|
||||
"openai" => OpenAiAsync(apiKey),
|
||||
"gemini" => GeminiAsync(apiKey),
|
||||
_ => ClaudeAsync(apiKey)
|
||||
};
|
||||
|
||||
private static async Task<List<string>> ClaudeAsync(string key)
|
||||
{
|
||||
var result = new List<string>();
|
||||
try
|
||||
{
|
||||
using var req = new HttpRequestMessage(HttpMethod.Get, "https://api.anthropic.com/v1/models?limit=100");
|
||||
req.Headers.Add("x-api-key", key);
|
||||
req.Headers.Add("anthropic-version", "2023-06-01");
|
||||
using var resp = await Http.SendAsync(req);
|
||||
if (!resp.IsSuccessStatusCode) return result;
|
||||
using var doc = JsonDocument.Parse(await resp.Content.ReadAsStringAsync());
|
||||
if (doc.RootElement.TryGetProperty("data", out var arr))
|
||||
foreach (var m in arr.EnumerateArray())
|
||||
if (m.TryGetProperty("id", out var id)) result.Add(id.GetString() ?? "");
|
||||
}
|
||||
catch { }
|
||||
return result;
|
||||
}
|
||||
|
||||
private static async Task<List<string>> OpenAiAsync(string key)
|
||||
{
|
||||
var result = new List<string>();
|
||||
try
|
||||
{
|
||||
using var req = new HttpRequestMessage(HttpMethod.Get, "https://api.openai.com/v1/models");
|
||||
req.Headers.Add("Authorization", "Bearer " + key);
|
||||
using var resp = await Http.SendAsync(req);
|
||||
if (!resp.IsSuccessStatusCode) return result;
|
||||
using var doc = JsonDocument.Parse(await resp.Content.ReadAsStringAsync());
|
||||
if (doc.RootElement.TryGetProperty("data", out var arr))
|
||||
foreach (var m in arr.EnumerateArray())
|
||||
if (m.TryGetProperty("id", out var idEl))
|
||||
{
|
||||
var id = idEl.GetString() ?? "";
|
||||
// chat-capable families only (skip embeddings/tts/whisper/image…)
|
||||
if (id.StartsWith("gpt") || id.StartsWith("o1") || id.StartsWith("o3") ||
|
||||
id.StartsWith("o4") || id.StartsWith("chatgpt"))
|
||||
result.Add(id);
|
||||
}
|
||||
result.Sort();
|
||||
}
|
||||
catch { }
|
||||
return result;
|
||||
}
|
||||
|
||||
private static async Task<List<string>> GeminiAsync(string key)
|
||||
{
|
||||
var result = new List<string>();
|
||||
try
|
||||
{
|
||||
var url = "https://generativelanguage.googleapis.com/v1beta/models?key=" + Uri.EscapeDataString(key);
|
||||
using var resp = await Http.GetAsync(url);
|
||||
if (!resp.IsSuccessStatusCode) return result;
|
||||
using var doc = JsonDocument.Parse(await resp.Content.ReadAsStringAsync());
|
||||
if (doc.RootElement.TryGetProperty("models", out var arr))
|
||||
foreach (var m in arr.EnumerateArray())
|
||||
{
|
||||
var supportsGen = false;
|
||||
if (m.TryGetProperty("supportedGenerationMethods", out var methods))
|
||||
foreach (var meth in methods.EnumerateArray())
|
||||
if (meth.GetString() == "generateContent") { supportsGen = true; break; }
|
||||
if (supportsGen && m.TryGetProperty("name", out var nameEl))
|
||||
{
|
||||
var name = nameEl.GetString() ?? "";
|
||||
if (name.StartsWith("models/")) name = name.Substring("models/".Length);
|
||||
result.Add(name);
|
||||
}
|
||||
}
|
||||
}
|
||||
catch { }
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
// PRE-GENERATED (design session). Free space on the drive where local models are stored.
|
||||
using System;
|
||||
using System.IO;
|
||||
|
||||
namespace DansoriEQ.App.Ai;
|
||||
|
||||
public static class DiskInfo
|
||||
{
|
||||
/// <summary>Free space (GB) on the drive that holds Ollama models (~%USERPROFILE%\.ollama).</summary>
|
||||
public static (double freeGb, string drive) ModelDriveFree()
|
||||
{
|
||||
var path = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".ollama");
|
||||
var root = Path.GetPathRoot(path) ?? "C:\\";
|
||||
try
|
||||
{
|
||||
var di = new DriveInfo(root);
|
||||
return (di.AvailableFreeSpace / 1024.0 / 1024.0 / 1024.0, root);
|
||||
}
|
||||
catch { return (0, root); }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
// PRE-GENERATED (design session). Talks to a local Ollama server (localhost:11434).
|
||||
// Detect / list models / pull with progress. Untested here.
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Net.Http;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace DansoriEQ.App.Ai;
|
||||
|
||||
public sealed class OllamaService
|
||||
{
|
||||
private const string Base = "http://localhost:11434";
|
||||
private readonly HttpClient _http = new() { Timeout = TimeSpan.FromMinutes(60) };
|
||||
|
||||
public async Task<bool> IsRunningAsync()
|
||||
{
|
||||
try { var r = await _http.GetAsync($"{Base}/api/tags"); return r.IsSuccessStatusCode; }
|
||||
catch { return false; }
|
||||
}
|
||||
|
||||
public async Task<List<string>> ListModelsAsync()
|
||||
{
|
||||
var models = new List<string>();
|
||||
try
|
||||
{
|
||||
var json = await _http.GetStringAsync($"{Base}/api/tags");
|
||||
using var doc = JsonDocument.Parse(json);
|
||||
if (doc.RootElement.TryGetProperty("models", out var arr))
|
||||
foreach (var m in arr.EnumerateArray())
|
||||
if (m.TryGetProperty("name", out var n)) models.Add(n.GetString() ?? "");
|
||||
}
|
||||
catch { }
|
||||
return models;
|
||||
}
|
||||
|
||||
public async Task PullModelAsync(string model, IProgress<double>? progress, CancellationToken ct = default)
|
||||
{
|
||||
var body = JsonSerializer.Serialize(new { name = model, stream = true });
|
||||
using var req = new HttpRequestMessage(HttpMethod.Post, $"{Base}/api/pull")
|
||||
{ Content = new StringContent(body, Encoding.UTF8, "application/json") };
|
||||
using var resp = await _http.SendAsync(req, HttpCompletionOption.ResponseHeadersRead, ct);
|
||||
resp.EnsureSuccessStatusCode();
|
||||
|
||||
using var stream = await resp.Content.ReadAsStreamAsync(ct);
|
||||
using var reader = new StreamReader(stream);
|
||||
string? line;
|
||||
while ((line = await reader.ReadLineAsync()) != null)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(line)) continue;
|
||||
try
|
||||
{
|
||||
using var doc = JsonDocument.Parse(line);
|
||||
if (doc.RootElement.TryGetProperty("total", out var tot) &&
|
||||
doc.RootElement.TryGetProperty("completed", out var comp))
|
||||
{
|
||||
double t = tot.GetInt64(), c = comp.GetInt64();
|
||||
if (t > 0) progress?.Report(c / t);
|
||||
}
|
||||
}
|
||||
catch { /* non-progress status line */ }
|
||||
}
|
||||
progress?.Report(1.0);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
// PRE-GENERATED (design session). Silent install of local AI runtimes via winget.
|
||||
// Untested here. winget must be present (Win10 1809+/Win11 usually have it).
|
||||
using System.Diagnostics;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace DansoriEQ.App.Ai;
|
||||
|
||||
public static class WingetInstaller
|
||||
{
|
||||
public static bool IsAvailable()
|
||||
{
|
||||
try
|
||||
{
|
||||
var psi = new ProcessStartInfo("winget", "--version")
|
||||
{ UseShellExecute = false, CreateNoWindow = true, RedirectStandardOutput = true };
|
||||
using var p = Process.Start(psi);
|
||||
if (p == null) return false;
|
||||
p.WaitForExit(4000);
|
||||
return p.ExitCode == 0;
|
||||
}
|
||||
catch { return false; }
|
||||
}
|
||||
|
||||
public static async Task<bool> InstallAsync(string wingetId)
|
||||
{
|
||||
var args = $"install --id {wingetId} -e --silent --accept-package-agreements --accept-source-agreements";
|
||||
var psi = new ProcessStartInfo("winget", args) { UseShellExecute = true }; // may prompt UAC
|
||||
try
|
||||
{
|
||||
using var p = Process.Start(psi);
|
||||
if (p == null) return false;
|
||||
await p.WaitForExitAsync();
|
||||
return p.ExitCode == 0;
|
||||
}
|
||||
catch { return false; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
<!-- PRE-GENERATED (design session). AI 관리: 활성 모델 선택 + 로컬 런타임 설치 + 모델 다운로드 + 저장공간 경고. -->
|
||||
<ui:FluentWindow x:Class="DansoriEQ.App.AiManagerWindow"
|
||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:ui="http://schemas.lepo.co/wpfui/2022/xaml"
|
||||
Title="AI 관리" Height="680" Width="640"
|
||||
WindowStartupLocation="CenterOwner"
|
||||
ExtendsContentIntoTitleBar="True" WindowBackdropType="Mica"
|
||||
Background="#1B1B1F" Foreground="#F2F3F5"
|
||||
FontFamily="Segoe UI Variable, Segoe UI" FontSize="14">
|
||||
<Window.Resources>
|
||||
<SolidColorBrush x:Key="Card" Color="#26262B"/>
|
||||
<SolidColorBrush x:Key="Card2" Color="#2D2D33"/>
|
||||
<SolidColorBrush x:Key="Stroke" Color="#3A3A42"/>
|
||||
<SolidColorBrush x:Key="Dim" Color="#A8ABB4"/>
|
||||
<SolidColorBrush x:Key="Mute" Color="#767A83"/>
|
||||
<SolidColorBrush x:Key="Accent" Color="#4CC2FF"/>
|
||||
</Window.Resources>
|
||||
|
||||
<Grid>
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition Height="Auto"/>
|
||||
<RowDefinition Height="*"/>
|
||||
</Grid.RowDefinitions>
|
||||
|
||||
<ui:TitleBar Grid.Row="0" Title="AI 관리" Foreground="#F2F3F5" Padding="14,6"/>
|
||||
|
||||
<Border Grid.Row="1" Style="{StaticResource NeonPopup}">
|
||||
<DockPanel Margin="20">
|
||||
<TextBlock DockPanel.Dock="Top" Text="AI 관리" FontSize="21" FontWeight="Bold" Margin="0,0,0,14"/>
|
||||
<Button DockPanel.Dock="Bottom" Content="닫기" Click="Close_Click" HorizontalAlignment="Right"
|
||||
Height="36" Padding="18,0" Margin="0,14,0,0"
|
||||
Background="{StaticResource Card}" Foreground="{StaticResource Dim}" BorderBrush="{StaticResource Stroke}"/>
|
||||
|
||||
<ScrollViewer VerticalScrollBarVisibility="Auto">
|
||||
<StackPanel>
|
||||
|
||||
<!-- 1. 활성 AI -->
|
||||
<Border Background="{StaticResource Card}" BorderBrush="{StaticResource Stroke}" BorderThickness="1" CornerRadius="12" Padding="16" Margin="0,0,0,14">
|
||||
<StackPanel>
|
||||
<TextBlock Text="활성 AI 선택" FontWeight="Bold" Foreground="{StaticResource Dim}" Margin="0,0,0,10"/>
|
||||
<Grid>
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="Auto"/><ColumnDefinition Width="*"/>
|
||||
<ColumnDefinition Width="Auto"/><ColumnDefinition Width="*"/>
|
||||
</Grid.ColumnDefinitions>
|
||||
<TextBlock Text="제공자" VerticalAlignment="Center" Foreground="{StaticResource Dim}" Margin="0,0,8,0"/>
|
||||
<ComboBox x:Name="ProviderCombo" Grid.Column="1" SelectionChanged="Provider_Changed" Background="#2D2D33" Foreground="#F2F3F5" Margin="0,0,12,0">
|
||||
<ComboBoxItem>Claude (클라우드)</ComboBoxItem>
|
||||
<ComboBoxItem>ChatGPT · OpenAI (클라우드)</ComboBoxItem>
|
||||
<ComboBoxItem>Gemini (클라우드)</ComboBoxItem>
|
||||
<ComboBoxItem>Ollama (로컬)</ComboBoxItem>
|
||||
</ComboBox>
|
||||
<TextBlock Text="모델" Grid.Column="2" VerticalAlignment="Center" Foreground="{StaticResource Dim}" Margin="0,0,8,0"/>
|
||||
<ComboBox x:Name="ModelCombo" Grid.Column="3" IsEditable="True" SelectionChanged="Model_Changed" Background="#2D2D33" Foreground="#F2F3F5"/>
|
||||
</Grid>
|
||||
<StackPanel Orientation="Horizontal" Margin="0,10,0,0">
|
||||
<Button Content="🔄 계정 모델 불러오기" Click="RefreshModels_Click" Height="30" Padding="12,0"
|
||||
Background="{StaticResource Card2}" Foreground="{StaticResource Dim}" BorderBrush="{StaticResource Stroke}"/>
|
||||
<Button Content="🔌 연결 테스트" Click="TestConnection_Click" Height="30" Padding="12,0" Margin="8,0,0,0"
|
||||
Background="{StaticResource Card2}" Foreground="{StaticResource Dim}" BorderBrush="{StaticResource Stroke}"/>
|
||||
<TextBlock x:Name="ModelStatus" VerticalAlignment="Center" Foreground="{StaticResource Mute}" FontSize="12" Margin="10,0,0,0"/>
|
||||
</StackPanel>
|
||||
<TextBlock Foreground="{StaticResource Mute}" FontSize="12" Margin="0,8,0,0" TextWrapping="Wrap"
|
||||
Text="클라우드는 API 키가 있으면 '계정에서 사용 가능한 모델'을 불러오고, 없으면 기본 목록을 씁니다. 직접 입력도 가능하며, 로컬(Ollama)은 다운로드된 모델만 표시됩니다."/>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<!-- 2. 로컬 런타임 설치 -->
|
||||
<Border Background="{StaticResource Card}" BorderBrush="{StaticResource Stroke}" BorderThickness="1" CornerRadius="12" Padding="16" Margin="0,0,0,14">
|
||||
<StackPanel>
|
||||
<TextBlock Text="로컬 AI 런타임 (자동 설치)" FontWeight="Bold" Foreground="{StaticResource Dim}" Margin="0,0,0,4"/>
|
||||
<TextBlock x:Name="WingetNote" Foreground="{StaticResource Mute}" FontSize="12" Margin="0,0,0,10"
|
||||
Text="winget으로 자동 설치합니다."/>
|
||||
|
||||
<Grid Margin="0,0,0,8">
|
||||
<Grid.ColumnDefinitions><ColumnDefinition Width="*"/><ColumnDefinition Width="Auto"/><ColumnDefinition Width="Auto"/></Grid.ColumnDefinitions>
|
||||
<StackPanel Grid.Column="0"><TextBlock Text="Ollama" FontWeight="SemiBold"/><TextBlock Text="OpenAI 호환 · per-user(무권한·무재부팅)" Foreground="{StaticResource Mute}" FontSize="12"/></StackPanel>
|
||||
<TextBlock x:Name="OllamaStatus" Grid.Column="1" VerticalAlignment="Center" Foreground="{StaticResource Dim}" FontSize="12" Margin="0,0,10,0" Text="확인 중…"/>
|
||||
<Button Grid.Column="2" Content="설치" Tag="Ollama.Ollama" Click="Install_Click" Height="30" Padding="16,0" Background="{StaticResource Accent}" Foreground="#04222F" FontWeight="Bold" BorderThickness="0"/>
|
||||
</Grid>
|
||||
<TextBlock Foreground="{StaticResource Mute}" FontSize="12" Margin="0,8,0,0" TextWrapping="Wrap"
|
||||
Text="※ 현재 무인 자동설치가 확실한 로컬 런타임은 Ollama뿐입니다. LM Studio·GPT4All 등은 검증 후 추가 예정."/>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<!-- 3. Ollama 모델 다운로드 -->
|
||||
<Border Background="{StaticResource Card}" BorderBrush="{StaticResource Stroke}" BorderThickness="1" CornerRadius="12" Padding="16" Margin="0,0,0,14">
|
||||
<StackPanel>
|
||||
<TextBlock Text="로컬 모델 (Ollama)" FontWeight="Bold" Foreground="{StaticResource Dim}" Margin="0,0,0,10"/>
|
||||
<Grid>
|
||||
<Grid.ColumnDefinitions><ColumnDefinition Width="*"/><ColumnDefinition Width="Auto"/></Grid.ColumnDefinitions>
|
||||
<ComboBox x:Name="OllamaModelCombo" Grid.Column="0" IsEditable="True" Background="#2D2D33" Foreground="#F2F3F5">
|
||||
<ComboBoxItem>phi3.5</ComboBoxItem>
|
||||
<ComboBoxItem>llama3.2</ComboBoxItem>
|
||||
<ComboBoxItem>qwen2.5:3b</ComboBoxItem>
|
||||
<ComboBoxItem>gemma2:2b</ComboBoxItem>
|
||||
</ComboBox>
|
||||
<Button x:Name="PullBtn" Grid.Column="1" Content="⬇ 다운로드" Click="Pull_Click" Height="32" Padding="14,0" Margin="8,0,0,0" Background="{StaticResource Accent}" Foreground="#04222F" FontWeight="Bold" BorderThickness="0"/>
|
||||
</Grid>
|
||||
<Grid Margin="0,10,0,0">
|
||||
<Grid.ColumnDefinitions><ColumnDefinition Width="*"/><ColumnDefinition Width="Auto"/></Grid.ColumnDefinitions>
|
||||
<ProgressBar x:Name="PullProgress" Grid.Column="0" Height="8" Minimum="0" Maximum="100" Background="#2D2D33" Foreground="{StaticResource Accent}" BorderThickness="0"/>
|
||||
<TextBlock x:Name="PullStatus" Grid.Column="1" VerticalAlignment="Center" Foreground="{StaticResource Mute}" FontSize="12" Margin="10,0,0,0" Text="대기"/>
|
||||
</Grid>
|
||||
<TextBlock Text="설치된 모델" Foreground="{StaticResource Mute}" FontSize="12" Margin="0,12,0,4"/>
|
||||
<ListBox x:Name="InstalledList" Height="80" BorderThickness="0" Background="#2D2D33" Foreground="{StaticResource Dim}"/>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<!-- 4. 저장공간 경고 -->
|
||||
<Border Background="#2A2420" BorderBrush="#5a4a30" BorderThickness="1" CornerRadius="12" Padding="16">
|
||||
<StackPanel>
|
||||
<TextBlock Text="⚠️ 저장 공간 안내" FontWeight="Bold" Foreground="#FFB454"/>
|
||||
<TextBlock Foreground="{StaticResource Dim}" FontSize="13" TextWrapping="Wrap" Margin="0,8,0,0"
|
||||
Text="로컬 AI 모델은 개당 약 2~8GB이며, 여러 모델을 받거나 큰 모델(수십B급)을 쓰면 전체적으로 수십~수백 GB가 필요할 수 있습니다. 넉넉한 여유 공간(최소 20GB 이상, 여러 모델 사용 시 100GB+)을 권장합니다."/>
|
||||
<TextBlock x:Name="DiskText" Foreground="#FFB454" FontWeight="SemiBold" Margin="0,10,0,0" Text="여유 공간 확인 중…"/>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
</StackPanel>
|
||||
</ScrollViewer>
|
||||
</DockPanel>
|
||||
</Border>
|
||||
</Grid>
|
||||
</ui:FluentWindow>
|
||||
@@ -0,0 +1,181 @@
|
||||
// PRE-GENERATED (design session). AI 관리 창 code-behind (계정 모델 목록 동적 로드 포함).
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading.Tasks;
|
||||
using System.Windows;
|
||||
using System.Windows.Controls;
|
||||
using DansoriEQ.App.Ai;
|
||||
using DansoriEQ.Core.Ai;
|
||||
using DansoriEQ.Core.Security;
|
||||
|
||||
namespace DansoriEQ.App;
|
||||
|
||||
public partial class AiManagerWindow : Wpf.Ui.Controls.FluentWindow
|
||||
{
|
||||
private readonly OllamaService _ollama = new();
|
||||
private readonly ISecretStore _store;
|
||||
private List<string> _installedOllama = new();
|
||||
|
||||
public AiManagerWindow(ISecretStore store)
|
||||
{
|
||||
InitializeComponent();
|
||||
_store = store;
|
||||
Loaded += OnLoaded;
|
||||
}
|
||||
|
||||
private async void OnLoaded(object sender, RoutedEventArgs e)
|
||||
{
|
||||
var (free, drive) = DiskInfo.ModelDriveFree();
|
||||
DiskText.Text = $"현재 여유 공간: 약 {free:0.0} GB (드라이브 {drive})";
|
||||
|
||||
if (!WingetInstaller.IsAvailable())
|
||||
WingetNote.Text = "⚠ winget이 감지되지 않습니다. 자동 설치가 제한될 수 있어요(수동 설치 필요).";
|
||||
|
||||
var s = AppSettings.Load();
|
||||
ProviderCombo.SelectedIndex = s.AiProvider switch { "openai" => 1, "gemini" => 2, "ollama" => 3, _ => 0 };
|
||||
|
||||
await RefreshOllamaAsync();
|
||||
await RefreshModelsAsync();
|
||||
}
|
||||
|
||||
private static string ProviderId(int index) => index switch { 1 => "openai", 2 => "gemini", 3 => "ollama", _ => "claude" };
|
||||
|
||||
private static string ProviderSecretKey(string provider) => provider switch
|
||||
{
|
||||
"openai" => ProviderKeys.OpenAi,
|
||||
"gemini" => ProviderKeys.Gemini,
|
||||
_ => ProviderKeys.Claude
|
||||
};
|
||||
|
||||
private static string[] StaticModels(string provider) => provider switch
|
||||
{
|
||||
"openai" => new[] { "gpt-4o", "gpt-4o-mini" },
|
||||
"gemini" => new[] { "gemini-2.0-flash", "gemini-2.0-pro" },
|
||||
_ => new[] { "claude-sonnet-4-6", "claude-opus-4-8" }
|
||||
};
|
||||
|
||||
private async void Provider_Changed(object sender, SelectionChangedEventArgs e) => await RefreshModelsAsync();
|
||||
private async void RefreshModels_Click(object sender, RoutedEventArgs e) => await RefreshModelsAsync();
|
||||
|
||||
private async void TestConnection_Click(object sender, RoutedEventArgs e)
|
||||
{
|
||||
var prov = ProviderId(ProviderCombo.SelectedIndex);
|
||||
ModelStatus.Text = "연결 테스트 중…";
|
||||
if (prov == "ollama")
|
||||
{
|
||||
ModelStatus.Text = await _ollama.IsRunningAsync() ? "✓ Ollama 연결됨" : "✗ Ollama 미실행";
|
||||
return;
|
||||
}
|
||||
var key = _store.Load(ProviderSecretKey(prov));
|
||||
if (string.IsNullOrEmpty(key)) { ModelStatus.Text = "✗ API 키 없음(설정에서 입력)"; return; }
|
||||
var models = await CloudModelLister.ListAsync(prov, key);
|
||||
ModelStatus.Text = models.Count > 0 ? $"✓ 연결 성공 (모델 {models.Count}개)" : "✗ 연결 실패 (키/네트워크 확인)";
|
||||
}
|
||||
private void Model_Changed(object sender, SelectionChangedEventArgs e) => SaveSelection();
|
||||
|
||||
/// <summary>Populate ModelCombo: local = downloaded models; cloud = account models (via key) with static fallback.</summary>
|
||||
private async Task RefreshModelsAsync()
|
||||
{
|
||||
var prov = ProviderId(ProviderCombo.SelectedIndex);
|
||||
|
||||
if (prov == "ollama")
|
||||
{
|
||||
ModelCombo.ItemsSource = _installedOllama.Count > 0
|
||||
? (IEnumerable<string>)_installedOllama.ToArray()
|
||||
: new[] { "(모델 없음 — 아래에서 다운로드)" };
|
||||
ModelStatus.Text = _installedOllama.Count > 0 ? $"로컬 모델 {_installedOllama.Count}개" : "다운로드 필요";
|
||||
RestoreSelection();
|
||||
SaveSelection();
|
||||
return;
|
||||
}
|
||||
|
||||
var key = _store.Load(ProviderSecretKey(prov));
|
||||
if (string.IsNullOrEmpty(key))
|
||||
{
|
||||
ModelCombo.ItemsSource = StaticModels(prov);
|
||||
ModelStatus.Text = "API 키 없음 — 기본 목록(설정에서 키 입력)";
|
||||
RestoreSelection();
|
||||
SaveSelection();
|
||||
return;
|
||||
}
|
||||
|
||||
ModelStatus.Text = "계정 모델 불러오는 중…";
|
||||
var models = await CloudModelLister.ListAsync(prov, key);
|
||||
if (models.Count > 0)
|
||||
{
|
||||
ModelCombo.ItemsSource = models;
|
||||
ModelStatus.Text = $"계정 모델 {models.Count}개";
|
||||
}
|
||||
else
|
||||
{
|
||||
ModelCombo.ItemsSource = StaticModels(prov);
|
||||
ModelStatus.Text = "불러오기 실패 — 기본 목록(직접 입력도 가능)";
|
||||
}
|
||||
RestoreSelection();
|
||||
SaveSelection();
|
||||
}
|
||||
|
||||
private void RestoreSelection()
|
||||
{
|
||||
var saved = AppSettings.Load().AiModel;
|
||||
if (saved != null && ModelCombo.Items.Contains(saved)) ModelCombo.SelectedItem = saved;
|
||||
else if (ModelCombo.Items.Count > 0) ModelCombo.SelectedIndex = 0;
|
||||
}
|
||||
|
||||
private void SaveSelection()
|
||||
{
|
||||
var s = AppSettings.Load();
|
||||
s.AiProvider = ProviderId(ProviderCombo.SelectedIndex);
|
||||
s.AiModel = string.IsNullOrWhiteSpace(ModelCombo.Text) ? ModelCombo.SelectedItem as string : ModelCombo.Text;
|
||||
s.Save();
|
||||
}
|
||||
|
||||
private async Task RefreshOllamaAsync()
|
||||
{
|
||||
if (await _ollama.IsRunningAsync())
|
||||
{
|
||||
OllamaStatus.Text = "감지됨 (실행 중)";
|
||||
_installedOllama = await _ollama.ListModelsAsync();
|
||||
}
|
||||
else
|
||||
{
|
||||
OllamaStatus.Text = "미설치 / 미실행";
|
||||
_installedOllama = new();
|
||||
}
|
||||
InstalledList.ItemsSource = _installedOllama;
|
||||
if (ProviderId(ProviderCombo.SelectedIndex) == "ollama") await RefreshModelsAsync();
|
||||
}
|
||||
|
||||
private async void Install_Click(object sender, RoutedEventArgs e)
|
||||
{
|
||||
if (sender is not FrameworkElement fe || fe.Tag is not string wingetId) return;
|
||||
if (!WingetInstaller.IsAvailable())
|
||||
{
|
||||
MessageBox.Show(this, "winget이 없어 자동 설치할 수 없습니다. 수동 설치가 필요합니다.", "안내");
|
||||
return;
|
||||
}
|
||||
if (sender is Button b) b.IsEnabled = false;
|
||||
var ok = await WingetInstaller.InstallAsync(wingetId);
|
||||
if (sender is Button b2) b2.IsEnabled = true;
|
||||
MessageBox.Show(this, ok ? "설치가 완료되었습니다." : "설치가 취소되었거나 실패했습니다.", "설치");
|
||||
await RefreshOllamaAsync();
|
||||
}
|
||||
|
||||
private async void Pull_Click(object sender, RoutedEventArgs e)
|
||||
{
|
||||
var model = OllamaModelCombo.Text?.Trim();
|
||||
if (string.IsNullOrEmpty(model)) return;
|
||||
if (!await _ollama.IsRunningAsync())
|
||||
{
|
||||
MessageBox.Show(this, "Ollama가 실행 중이 아닙니다. 먼저 위에서 Ollama를 설치/실행하세요.", "안내");
|
||||
return;
|
||||
}
|
||||
PullBtn.IsEnabled = false;
|
||||
var progress = new Progress<double>(p => { PullProgress.Value = p * 100; PullStatus.Text = $"다운로드 {p * 100:0}%"; });
|
||||
try { await _ollama.PullModelAsync(model, progress); PullStatus.Text = "완료 ✓"; await RefreshOllamaAsync(); }
|
||||
catch (Exception ex) { PullStatus.Text = "실패: " + ex.Message; }
|
||||
finally { PullBtn.IsEnabled = true; }
|
||||
}
|
||||
|
||||
private void Close_Click(object sender, RoutedEventArgs e) => Close();
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
using System;
|
||||
using System.Diagnostics;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
|
||||
namespace DansoriEQ.App.Apo;
|
||||
|
||||
public static class ApoConfigInstaller
|
||||
{
|
||||
private const string IncludeLine = "Include: ai_eq.txt";
|
||||
|
||||
public static bool IsIncluded(string configDir)
|
||||
{
|
||||
var path = Path.Combine(configDir, "config.txt");
|
||||
if (!File.Exists(path)) return false;
|
||||
return File.ReadAllLines(path)
|
||||
.Any(l => l.Trim().Equals(IncludeLine, StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
/// <summary>Tries direct write first; falls back to an elevated PowerShell call.</summary>
|
||||
public static bool TryAddInclude(string configDir)
|
||||
{
|
||||
if (IsIncluded(configDir)) return true;
|
||||
|
||||
var configTxt = Path.Combine(configDir, "config.txt");
|
||||
try
|
||||
{
|
||||
File.AppendAllText(configTxt, Environment.NewLine + IncludeLine + Environment.NewLine);
|
||||
return true;
|
||||
}
|
||||
catch (UnauthorizedAccessException)
|
||||
{
|
||||
return TryAddIncludeElevated(configTxt);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Ensure an empty ai_eq.txt exists so APO doesn't log a missing-file warning.</summary>
|
||||
public static void EnsureAiEqFile(string configDir)
|
||||
{
|
||||
var target = Path.Combine(configDir, "ai_eq.txt");
|
||||
if (!File.Exists(target))
|
||||
File.WriteAllText(target, "# Dansori EQ — managed automatically\n");
|
||||
}
|
||||
|
||||
private static bool TryAddIncludeElevated(string configTxtPath)
|
||||
{
|
||||
// Escape single-quotes in path for PowerShell
|
||||
var escaped = configTxtPath.Replace("'", "''");
|
||||
var psCmd = $"Add-Content -Encoding UTF8 -Path '{escaped}' -Value \"`r`nInclude: ai_eq.txt`r`n\"";
|
||||
try
|
||||
{
|
||||
var psi = new ProcessStartInfo("powershell.exe",
|
||||
$"-NonInteractive -WindowStyle Hidden -Command \"{psCmd}\"")
|
||||
{
|
||||
Verb = "runas",
|
||||
UseShellExecute = true,
|
||||
WindowStyle = ProcessWindowStyle.Hidden
|
||||
};
|
||||
using var proc = Process.Start(psi)
|
||||
?? throw new InvalidOperationException("Process.Start returned null");
|
||||
proc.WaitForExit(15_000);
|
||||
return IsIncluded(Path.GetDirectoryName(configTxtPath)!);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
// PRE-GENERATED (design session). Writes the live APO include file (ai_eq.txt).
|
||||
// NOTE: config.txt must Include this file + the file must be user-writable — that is the
|
||||
// one-time elevated setup (M3/M6). Untested on this machine (no APO installed here).
|
||||
using System.IO;
|
||||
using DansoriEQ.Core.Apo;
|
||||
|
||||
namespace DansoriEQ.App.Apo;
|
||||
|
||||
public sealed class ApoIncludeWriter : IApoWriter
|
||||
{
|
||||
private readonly string _includePath;
|
||||
|
||||
public ApoIncludeWriter(string configDir)
|
||||
=> _includePath = Path.Combine(configDir, "ai_eq.txt");
|
||||
|
||||
public bool IsLive => true;
|
||||
public string TargetPath => _includePath;
|
||||
public string? LastConfig { get; private set; }
|
||||
|
||||
public void Apply(string apoConfigText)
|
||||
{
|
||||
File.WriteAllText(_includePath, apoConfigText); // APO live-reloads on save
|
||||
LastConfig = apoConfigText;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
// PRE-GENERATED (design session). Downloads the official Equalizer APO installer and launches it.
|
||||
// NOTE: APO install is NOT silent — it needs device selection + a REBOOT. So "auto install"
|
||||
// = download official installer + launch it; the user completes the wizard and reboots.
|
||||
// Untested here (needs network + admin). Verify the download URL in the dev session.
|
||||
using System;
|
||||
using System.Diagnostics;
|
||||
using System.IO;
|
||||
using System.Net.Http;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace DansoriEQ.App.Apo;
|
||||
|
||||
public static class ApoInstaller
|
||||
{
|
||||
// SourceForge "latest" redirect for Equalizer APO. VERIFY current link in dev session.
|
||||
public const string DownloadUrl = "https://sourceforge.net/projects/equalizerapo/files/latest/download";
|
||||
|
||||
public static async Task<string> DownloadAsync(IProgress<double>? progress = null, CancellationToken ct = default)
|
||||
{
|
||||
var target = Path.Combine(Path.GetTempPath(), "EqualizerAPO-setup.exe");
|
||||
|
||||
using var http = new HttpClient();
|
||||
http.DefaultRequestHeaders.Add("User-Agent", "DansoriEQ");
|
||||
using var resp = await http.GetAsync(DownloadUrl, HttpCompletionOption.ResponseHeadersRead, ct);
|
||||
resp.EnsureSuccessStatusCode();
|
||||
|
||||
var total = resp.Content.Headers.ContentLength ?? -1L;
|
||||
await using var src = await resp.Content.ReadAsStreamAsync(ct);
|
||||
await using var dst = File.Create(target);
|
||||
|
||||
var buffer = new byte[81920];
|
||||
long readTotal = 0;
|
||||
int n;
|
||||
while ((n = await src.ReadAsync(buffer, ct)) > 0)
|
||||
{
|
||||
await dst.WriteAsync(buffer.AsMemory(0, n), ct);
|
||||
readTotal += n;
|
||||
if (total > 0) progress?.Report((double)readTotal / total);
|
||||
}
|
||||
return target;
|
||||
}
|
||||
|
||||
public static void Launch(string installerPath)
|
||||
=> Process.Start(new ProcessStartInfo(installerPath) { UseShellExecute = true }); // triggers UAC
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
// PRE-GENERATED (design session). Locates the Equalizer APO config dir via default paths.
|
||||
// Dev session may add a registry lookup (HKLM\SOFTWARE\EqualizerAPO) for robustness.
|
||||
using System;
|
||||
using System.IO;
|
||||
|
||||
namespace DansoriEQ.App.Apo;
|
||||
|
||||
public static class ApoLocator
|
||||
{
|
||||
public static string? FindConfigDir()
|
||||
{
|
||||
foreach (var pf in new[]
|
||||
{
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles),
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.ProgramFilesX86)
|
||||
})
|
||||
{
|
||||
if (string.IsNullOrEmpty(pf)) continue;
|
||||
var dir = Path.Combine(pf, "EqualizerAPO", "config");
|
||||
if (Directory.Exists(dir)) return dir;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
public static bool IsInstalled => FindConfigDir() != null;
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
// PRE-GENERATED (design session). Picks the live writer if APO is present, else preview mode.
|
||||
// Resolution order: user-set path (settings) -> default install location -> null (preview).
|
||||
using System.IO;
|
||||
using DansoriEQ.Core.Apo;
|
||||
|
||||
namespace DansoriEQ.App.Apo;
|
||||
|
||||
public static class ApoWriterFactory
|
||||
{
|
||||
public static string? ResolveConfigDir()
|
||||
{
|
||||
var s = AppSettings.Load();
|
||||
if (!string.IsNullOrEmpty(s.ApoConfigDir) && Directory.Exists(s.ApoConfigDir))
|
||||
return s.ApoConfigDir;
|
||||
return ApoLocator.FindConfigDir();
|
||||
}
|
||||
|
||||
public static IApoWriter Create()
|
||||
{
|
||||
var dir = ResolveConfigDir();
|
||||
return dir != null ? new ApoIncludeWriter(dir) : new NullApoWriter();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
// PRE-GENERATED (design session). Preview/dry-run writer used when APO is NOT installed.
|
||||
// Lets the whole EQ chain run offline-of-APO and writes the generated config to a preview
|
||||
// file so you can verify exactly what WOULD be applied.
|
||||
using System;
|
||||
using System.IO;
|
||||
using DansoriEQ.Core.Apo;
|
||||
|
||||
namespace DansoriEQ.App.Apo;
|
||||
|
||||
public sealed class NullApoWriter : IApoWriter
|
||||
{
|
||||
private readonly string _previewPath;
|
||||
|
||||
public NullApoWriter()
|
||||
{
|
||||
var dir = Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "DansoriEQ");
|
||||
Directory.CreateDirectory(dir);
|
||||
_previewPath = Path.Combine(dir, "preview_apo_config.txt");
|
||||
}
|
||||
|
||||
public bool IsLive => false;
|
||||
public string TargetPath => _previewPath;
|
||||
public string? LastConfig { get; private set; }
|
||||
|
||||
public void Apply(string apoConfigText)
|
||||
{
|
||||
LastConfig = apoConfigText;
|
||||
try { File.WriteAllText(_previewPath, apoConfigText); } catch { /* preview only */ }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
<ui:FluentWindow x:Class="DansoriEQ.App.ApoLinkWindow"
|
||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:ui="http://schemas.lepo.co/wpfui/2022/xaml"
|
||||
Title="APO 연동 설정" Height="300" Width="500"
|
||||
WindowStartupLocation="CenterScreen" ResizeMode="NoResize"
|
||||
ExtendsContentIntoTitleBar="True" WindowBackdropType="Mica"
|
||||
Background="#1B1B1F" Foreground="#F2F3F5"
|
||||
FontFamily="Segoe UI Variable, Segoe UI">
|
||||
<Grid>
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition Height="Auto"/>
|
||||
<RowDefinition Height="*"/>
|
||||
</Grid.RowDefinitions>
|
||||
|
||||
<ui:TitleBar Grid.Row="0" Title="APO 연동 설정" Foreground="#F2F3F5" Padding="14,6"/>
|
||||
|
||||
<Border Grid.Row="1" Style="{StaticResource NeonPopup}">
|
||||
<DockPanel Margin="24">
|
||||
|
||||
<StackPanel DockPanel.Dock="Bottom" Orientation="Horizontal"
|
||||
HorizontalAlignment="Right" Margin="0,16,0,0">
|
||||
<Button x:Name="BtnNo" Content="나중에" Width="90" Height="36"
|
||||
Click="BtnNo_Click" Margin="0,0,10,0"
|
||||
Background="#26262B" Foreground="#A8ABB4" BorderBrush="#3A3A42"/>
|
||||
<Button x:Name="BtnYes" Content="예, 연동 설정" Width="130" Height="36"
|
||||
Click="BtnYes_Click"
|
||||
Background="#4CC2FF" Foreground="#04222F"
|
||||
FontWeight="Bold" BorderThickness="0"/>
|
||||
</StackPanel>
|
||||
|
||||
<StackPanel>
|
||||
<TextBlock Text="Equalizer APO 연동 설정"
|
||||
FontSize="16" FontWeight="Bold" Margin="0,0,0,16"/>
|
||||
|
||||
<Border Background="#26262B" BorderBrush="#3A3A42"
|
||||
BorderThickness="1" CornerRadius="10" Padding="16,14">
|
||||
<StackPanel>
|
||||
<TextBlock TextWrapping="Wrap" Foreground="#A8ABB4" FontSize="13" LineHeight="20"
|
||||
Text="EQ를 실제 소리에 반영하려면 APO config.txt에 Include 줄을 한 번만 추가해야 합니다."/>
|
||||
<TextBlock Foreground="#767A83" FontSize="11.5" Margin="0,10,0,0"
|
||||
Text="※ 관리자 권한(UAC)이 요청됩니다. 이 작업은 최초 1회만 필요하며, 이후 EQ 변경은 권한 없이 자동 적용됩니다."/>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<TextBlock x:Name="StatusText" Foreground="#767A83" FontSize="11"
|
||||
Margin="0,14,0,0" TextWrapping="Wrap"/>
|
||||
</StackPanel>
|
||||
</DockPanel>
|
||||
</Border>
|
||||
</Grid>
|
||||
</ui:FluentWindow>
|
||||
@@ -0,0 +1,43 @@
|
||||
using System.Windows;
|
||||
using DansoriEQ.App.Apo;
|
||||
|
||||
namespace DansoriEQ.App;
|
||||
|
||||
public partial class ApoLinkWindow : Wpf.Ui.Controls.FluentWindow
|
||||
{
|
||||
private readonly string _configDir;
|
||||
|
||||
public ApoLinkWindow(string configDir)
|
||||
{
|
||||
InitializeComponent();
|
||||
_configDir = configDir;
|
||||
}
|
||||
|
||||
private void BtnYes_Click(object sender, RoutedEventArgs e)
|
||||
{
|
||||
BtnYes.IsEnabled = false;
|
||||
BtnNo.IsEnabled = false;
|
||||
StatusText.Text = "관리자 권한 요청 중…";
|
||||
|
||||
bool ok = ApoConfigInstaller.TryAddInclude(_configDir);
|
||||
if (ok)
|
||||
{
|
||||
ApoConfigInstaller.EnsureAiEqFile(_configDir);
|
||||
StatusText.Text = "✓ 연동 완료. EQ 변경이 즉시 소리에 반영됩니다.";
|
||||
}
|
||||
else
|
||||
{
|
||||
StatusText.Foreground = System.Windows.Media.Brushes.OrangeRed;
|
||||
StatusText.Text =
|
||||
$"자동 설정 실패. 수동으로 아래 파일에 'Include: ai_eq.txt'를 추가해주세요.\n{_configDir}\\config.txt";
|
||||
}
|
||||
|
||||
BtnYes.Content = "닫기";
|
||||
BtnYes.IsEnabled = true;
|
||||
BtnYes.Click -= BtnYes_Click;
|
||||
BtnYes.Click += (_, __) => Close();
|
||||
BtnNo.Visibility = Visibility.Collapsed;
|
||||
}
|
||||
|
||||
private void BtnNo_Click(object sender, RoutedEventArgs e) => Close();
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
<!-- PRE-GENERATED (design session). Shown at startup when APO isn't found.
|
||||
Modern chrome: FluentWindow + ui:TitleBar (Mica), matching DbManagerWindow. -->
|
||||
<ui:FluentWindow x:Class="DansoriEQ.App.ApoSetupWindow"
|
||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:ui="http://schemas.lepo.co/wpfui/2022/xaml"
|
||||
Title="Equalizer APO 설정" Height="430" Width="560"
|
||||
WindowStartupLocation="CenterScreen" ResizeMode="NoResize"
|
||||
ExtendsContentIntoTitleBar="True" WindowBackdropType="Mica"
|
||||
Background="#1B1B1F" Foreground="#F2F3F5"
|
||||
FontFamily="Segoe UI Variable, Segoe UI">
|
||||
<Window.Resources>
|
||||
<SolidColorBrush x:Key="Card" Color="#26262B"/>
|
||||
<SolidColorBrush x:Key="Stroke" Color="#3A3A42"/>
|
||||
<SolidColorBrush x:Key="TextDim" Color="#A8ABB4"/>
|
||||
<SolidColorBrush x:Key="TextMute" Color="#767A83"/>
|
||||
</Window.Resources>
|
||||
|
||||
<Grid>
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition Height="Auto"/>
|
||||
<RowDefinition Height="*"/>
|
||||
</Grid.RowDefinitions>
|
||||
|
||||
<ui:TitleBar Grid.Row="0" Title="Equalizer APO 설정"
|
||||
Foreground="#F2F3F5" Padding="14,6">
|
||||
<ui:TitleBar.Icon>
|
||||
<ui:SymbolIcon Symbol="Options24"/>
|
||||
</ui:TitleBar.Icon>
|
||||
</ui:TitleBar>
|
||||
|
||||
<Border Grid.Row="1" Style="{StaticResource NeonPopup}">
|
||||
<StackPanel Margin="24,8,24,24">
|
||||
<TextBlock Text="Equalizer APO가 감지되지 않았습니다" FontSize="18" FontWeight="Bold"/>
|
||||
<TextBlock Foreground="{StaticResource TextDim}" FontSize="13" TextWrapping="Wrap" Margin="0,8,0,18"
|
||||
Text="이 앱은 Equalizer APO를 통해 EQ를 적용합니다. 아래에서 설치하거나, 이미 설치돼 있다면 config 폴더 경로를 지정하세요. 지금은 미리보기 모드로 계속할 수도 있습니다."/>
|
||||
|
||||
<Border Background="{StaticResource Card}" BorderBrush="{StaticResource Stroke}" BorderThickness="1" CornerRadius="12" Padding="16">
|
||||
<StackPanel>
|
||||
<Button x:Name="BtnAutoInstall" Content="⬇ APO 자동 설치 (공식 설치 파일 다운로드·실행)" Click="BtnAutoInstall_Click"
|
||||
Height="40" HorizontalContentAlignment="Left" Padding="12,0" Margin="0,0,0,8"
|
||||
Background="#4CC2FF" Foreground="#04222F" FontWeight="Bold" BorderThickness="0"/>
|
||||
<Button x:Name="BtnSetPath" Content="📁 APO config 폴더 직접 지정" Click="BtnSetPath_Click"
|
||||
Height="40" HorizontalContentAlignment="Left" Padding="12,0" Margin="0,0,0,8"
|
||||
Background="#2D2D33" Foreground="{StaticResource TextDim}" BorderBrush="{StaticResource Stroke}"/>
|
||||
<Button x:Name="BtnLater" Content="나중에 (미리보기 모드로 계속)" Click="BtnLater_Click"
|
||||
Height="36" HorizontalContentAlignment="Left" Padding="12,0"
|
||||
Background="Transparent" Foreground="{StaticResource TextMute}" BorderThickness="0"/>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<ProgressBar x:Name="Prog" Height="8" Minimum="0" Maximum="100" Value="0" Margin="0,16,0,6"
|
||||
Background="#2D2D33" Foreground="#4CC2FF" BorderThickness="0"/>
|
||||
<TextBlock x:Name="TxtStatus" Foreground="{StaticResource TextMute}" FontSize="12"
|
||||
Text="APO 설치는 장치 선택과 재부팅이 필요합니다. 재부팅 후 앱을 다시 실행하세요."/>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</Grid>
|
||||
</ui:FluentWindow>
|
||||
@@ -0,0 +1,47 @@
|
||||
// PRE-GENERATED (design session). Startup APO setup dialog code-behind.
|
||||
using System;
|
||||
using System.Windows;
|
||||
using DansoriEQ.App.Apo;
|
||||
|
||||
namespace DansoriEQ.App;
|
||||
|
||||
public partial class ApoSetupWindow : Wpf.Ui.Controls.FluentWindow
|
||||
{
|
||||
public ApoSetupWindow() => InitializeComponent();
|
||||
|
||||
private async void BtnAutoInstall_Click(object sender, RoutedEventArgs e)
|
||||
{
|
||||
BtnAutoInstall.IsEnabled = false;
|
||||
var progress = new Progress<double>(p =>
|
||||
{
|
||||
Prog.Value = p * 100;
|
||||
TxtStatus.Text = $"설치 파일 다운로드 중… {p * 100:0}%";
|
||||
});
|
||||
try
|
||||
{
|
||||
var path = await ApoInstaller.DownloadAsync(progress);
|
||||
TxtStatus.Text = "설치 관리자를 실행합니다. 장치 선택 후 재부팅하고, 재부팅 후 앱을 다시 실행하세요.";
|
||||
ApoInstaller.Launch(path);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
TxtStatus.Text = "다운로드 실패: " + ex.Message;
|
||||
BtnAutoInstall.IsEnabled = true;
|
||||
}
|
||||
}
|
||||
|
||||
private void BtnSetPath_Click(object sender, RoutedEventArgs e)
|
||||
{
|
||||
var dlg = new Microsoft.Win32.OpenFolderDialog { Title = "Equalizer APO의 config 폴더를 선택하세요" };
|
||||
if (dlg.ShowDialog() == true)
|
||||
{
|
||||
var s = AppSettings.Load();
|
||||
s.ApoConfigDir = dlg.FolderName;
|
||||
s.Save();
|
||||
TxtStatus.Text = "APO 경로가 설정되었습니다.";
|
||||
DialogResult = true;
|
||||
}
|
||||
}
|
||||
|
||||
private void BtnLater_Click(object sender, RoutedEventArgs e) => Close();
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
<Application x:Class="DansoriEQ.App.App"
|
||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:ui="http://schemas.lepo.co/wpfui/2022/xaml">
|
||||
<Application.Resources>
|
||||
<ResourceDictionary>
|
||||
<ResourceDictionary.MergedDictionaries>
|
||||
<ui:ThemesDictionary Theme="Dark" />
|
||||
<ui:ControlsDictionary />
|
||||
</ResourceDictionary.MergedDictionaries>
|
||||
|
||||
<!-- Neon popup frame: bright accent border so dialogs separate from the dark background. -->
|
||||
<Style x:Key="NeonPopup" TargetType="Border">
|
||||
<Setter Property="Background" Value="#1B1B1F"/>
|
||||
<Setter Property="BorderBrush" Value="#4CC2FF"/>
|
||||
<Setter Property="BorderThickness" Value="1.5"/>
|
||||
<Setter Property="CornerRadius" Value="10"/>
|
||||
<Setter Property="Margin" Value="6"/>
|
||||
<Setter Property="Effect">
|
||||
<Setter.Value>
|
||||
<DropShadowEffect Color="#4CC2FF" BlurRadius="20" ShadowDepth="0" Opacity="0.45"/>
|
||||
</Setter.Value>
|
||||
</Setter>
|
||||
</Style>
|
||||
</ResourceDictionary>
|
||||
</Application.Resources>
|
||||
</Application>
|
||||
@@ -0,0 +1,70 @@
|
||||
using System.Windows;
|
||||
using DansoriEQ.App.Apo;
|
||||
using Wpf.Ui.Appearance;
|
||||
|
||||
namespace DansoriEQ.App;
|
||||
|
||||
public partial class App : Application
|
||||
{
|
||||
protected override void OnStartup(StartupEventArgs e)
|
||||
{
|
||||
base.OnStartup(e);
|
||||
|
||||
// PoC hook (Path B / Lottie). Run "DansoriEQ.App.exe --lottie" to open only the
|
||||
// Lottie test window, bypassing the normal splash/APO/main flow. Remove after decision.
|
||||
if (e.Args.Length > 0 && e.Args[0] == "--lottie")
|
||||
{
|
||||
ShutdownMode = ShutdownMode.OnMainWindowClose;
|
||||
var poc = new LottiePocWindow();
|
||||
MainWindow = poc;
|
||||
poc.Show();
|
||||
return;
|
||||
}
|
||||
|
||||
// Keep the app alive while pre-main dialogs (splash / APO setup) open and close;
|
||||
// otherwise closing the splash with no other window would shut the app down.
|
||||
ShutdownMode = ShutdownMode.OnExplicitShutdown;
|
||||
|
||||
// Surface unhandled UI-thread exceptions instead of silently terminating.
|
||||
DispatcherUnhandledException += (_, args) =>
|
||||
{
|
||||
System.Windows.MessageBox.Show(
|
||||
args.Exception.ToString(),
|
||||
"오류가 발생했습니다",
|
||||
MessageBoxButton.OK, MessageBoxImage.Error);
|
||||
args.Handled = true; // keep the app alive
|
||||
};
|
||||
|
||||
var s = AppSettings.Load();
|
||||
var theme = s.Theme switch
|
||||
{
|
||||
"light" => ApplicationTheme.Light,
|
||||
_ => ApplicationTheme.Dark
|
||||
};
|
||||
ApplicationThemeManager.Apply(theme);
|
||||
|
||||
// Brand splash (이소리·이단) — brief, before the setup flow.
|
||||
try { new SplashWindow().ShowDialog(); } catch { /* splash is non-essential */ }
|
||||
|
||||
var apoDir = ApoWriterFactory.ResolveConfigDir();
|
||||
if (apoDir is null)
|
||||
{
|
||||
new ApoSetupWindow().ShowDialog();
|
||||
}
|
||||
else if (!ApoConfigInstaller.IsIncluded(apoDir))
|
||||
{
|
||||
new ApoLinkWindow(apoDir).ShowDialog();
|
||||
}
|
||||
else
|
||||
{
|
||||
ApoConfigInstaller.EnsureAiEqFile(apoDir);
|
||||
}
|
||||
|
||||
var main = new MainWindow();
|
||||
MainWindow = main;
|
||||
main.Show();
|
||||
|
||||
// Now that the main window exists, tie app lifetime to it.
|
||||
ShutdownMode = ShutdownMode.OnMainWindowClose;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
// PRE-GENERATED (design session). Common local paths.
|
||||
using System;
|
||||
using System.IO;
|
||||
|
||||
namespace DansoriEQ.App;
|
||||
|
||||
public static class AppPaths
|
||||
{
|
||||
public static string Root
|
||||
{
|
||||
get
|
||||
{
|
||||
var d = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "DansoriEQ");
|
||||
Directory.CreateDirectory(d);
|
||||
return d;
|
||||
}
|
||||
}
|
||||
|
||||
public static string PresetsDir => Path.Combine(Root, "presets");
|
||||
public static string DbPath => Path.Combine(Root, "dansorieq.db");
|
||||
public static string SwitchRulesFile => Path.Combine(Root, "switch_rules.json");
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
// PRE-GENERATED (design session). Small persisted app settings (APO path override, etc.).
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace DansoriEQ.App;
|
||||
|
||||
public sealed class AppSettings
|
||||
{
|
||||
public string? ApoConfigDir { get; set; }
|
||||
public string? AiProvider { get; set; } // claude | openai | gemini | ollama
|
||||
public string? AiModel { get; set; }
|
||||
public bool AutoSwitchEnabled { get; set; }
|
||||
public string? Theme { get; set; } // "dark" | "light" | "system" (default: dark)
|
||||
|
||||
private static string FilePath
|
||||
{
|
||||
get
|
||||
{
|
||||
var dir = Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "DansoriEQ");
|
||||
Directory.CreateDirectory(dir);
|
||||
return Path.Combine(dir, "settings.json");
|
||||
}
|
||||
}
|
||||
|
||||
public static AppSettings Load()
|
||||
{
|
||||
try
|
||||
{
|
||||
return File.Exists(FilePath)
|
||||
? JsonSerializer.Deserialize<AppSettings>(File.ReadAllText(FilePath)) ?? new()
|
||||
: new();
|
||||
}
|
||||
catch { return new(); }
|
||||
}
|
||||
|
||||
public void Save() => File.WriteAllText(FilePath, JsonSerializer.Serialize(this));
|
||||
}
|
||||
|
After Width: | Height: | Size: 1.8 MiB |
|
After Width: | Height: | Size: 1.5 MiB |
|
After Width: | Height: | Size: 944 KiB |
|
After Width: | Height: | Size: 2.0 MiB |
|
After Width: | Height: | Size: 263 KiB |
|
After Width: | Height: | Size: 260 KiB |
|
After Width: | Height: | Size: 260 KiB |
|
After Width: | Height: | Size: 261 KiB |
|
After Width: | Height: | Size: 260 KiB |
|
After Width: | Height: | Size: 260 KiB |
|
After Width: | Height: | Size: 260 KiB |
|
After Width: | Height: | Size: 262 KiB |
|
After Width: | Height: | Size: 271 KiB |
|
After Width: | Height: | Size: 259 KiB |
|
After Width: | Height: | Size: 261 KiB |
|
After Width: | Height: | Size: 260 KiB |
|
After Width: | Height: | Size: 260 KiB |
|
After Width: | Height: | Size: 260 KiB |
|
After Width: | Height: | Size: 260 KiB |
|
After Width: | Height: | Size: 261 KiB |
|
After Width: | Height: | Size: 261 KiB |
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"name": "LeeSori",
|
||||
"status": "legacy_canvas_reference",
|
||||
"live2dNote": "Final WPF mascot target is Live2D Cubism. This file is kept only as Canvas FK/A-pose reference; use live2d_parameters.json for Cubism rigging.",
|
||||
"canvas": { "width": 520, "height": 900 },
|
||||
"imageBase": "../03_Assets/Parts/Images/",
|
||||
"mode": "fullcanvas",
|
||||
"note": "Full-canvas parts: each PNG is 520x900 with the part already at its master position. Renderer draws each image at the ORIGIN (0,0) and rotates it about its pivot (the joint). pivot = auto-derived centroid of the opaque overlap between the part and its parent. At rest (all rot/tx/ty = 0) every world matrix is identity, so stacking reproduces the master.",
|
||||
"bones": [
|
||||
{ "name": "pelvis", "parent": null, "pivot": [259.6, 363.4], "z": 6, "image": "sori_part_pelvis.png" },
|
||||
{ "name": "chest", "parent": "pelvis", "pivot": [259.6, 363.4], "z": 8, "image": "sori_part_chest.png" },
|
||||
{ "name": "neck", "parent": "chest", "pivot": [262.0, 229.3], "z": 9, "image": "sori_part_neck.png" },
|
||||
{ "name": "head", "parent": "neck", "pivot": [259.8, 209.3], "z": 10, "image": "sori_part_head.png" },
|
||||
|
||||
{ "name": "upperarm_r", "parent": "chest", "pivot": [174.2, 287.2], "z": 5, "image": "sori_part_upperarm_r.png" },
|
||||
{ "name": "forearm_r", "parent": "upperarm_r", "pivot": [137.3, 358.0], "z": 5, "image": "sori_part_forearm_r.png" },
|
||||
{ "name": "hand_r", "parent": "forearm_r", "pivot": [134.2, 400.8], "z": 5, "image": "sori_part_hand_r.png" },
|
||||
|
||||
{ "name": "upperarm_l", "parent": "chest", "pivot": [346.0, 286.5], "z": 12, "image": "sori_part_upperarm_l.png" },
|
||||
{ "name": "forearm_l", "parent": "upperarm_l", "pivot": [382.9, 357.6], "z": 12, "image": "sori_part_forearm_l.png" },
|
||||
{ "name": "hand_l", "parent": "forearm_l", "pivot": [389.6, 400.6], "z": 13, "image": "sori_part_hand_l.png" },
|
||||
|
||||
{ "name": "thigh_r", "parent": "pelvis", "pivot": [226.3, 455.0], "z": 4, "image": "sori_part_thigh_r.png" },
|
||||
{ "name": "shin_r", "parent": "thigh_r", "pivot": [233.3, 609.1], "z": 3, "image": "sori_part_shin_r.png" },
|
||||
{ "name": "foot_r", "parent": "shin_r", "pivot": [236.5, 729.4], "z": 2, "image": "sori_part_foot_r.png" },
|
||||
|
||||
{ "name": "thigh_l", "parent": "pelvis", "pivot": [294.1, 455.0], "z": 4, "image": "sori_part_thigh_l.png" },
|
||||
{ "name": "shin_l", "parent": "thigh_l", "pivot": [286.9, 609.1], "z": 3, "image": "sori_part_shin_l.png" },
|
||||
{ "name": "foot_l", "parent": "shin_l", "pivot": [283.9, 729.5], "z": 2, "image": "sori_part_foot_l.png" }
|
||||
]
|
||||
}
|
||||
|
After Width: | Height: | Size: 4.3 KiB |
|
After Width: | Height: | Size: 639 KiB |
|
After Width: | Height: | Size: 636 KiB |
|
After Width: | Height: | Size: 636 KiB |
|
After Width: | Height: | Size: 638 KiB |
|
After Width: | Height: | Size: 638 KiB |
|
After Width: | Height: | Size: 636 KiB |
|
After Width: | Height: | Size: 636 KiB |
|
After Width: | Height: | Size: 640 KiB |
|
After Width: | Height: | Size: 640 KiB |
|
After Width: | Height: | Size: 639 KiB |
|
After Width: | Height: | Size: 639 KiB |
|
After Width: | Height: | Size: 639 KiB |
|
After Width: | Height: | Size: 640 KiB |
|
After Width: | Height: | Size: 639 KiB |
|
After Width: | Height: | Size: 640 KiB |
|
After Width: | Height: | Size: 944 KiB |
|
After Width: | Height: | Size: 1.2 MiB |
|
After Width: | Height: | Size: 853 KiB |
|
After Width: | Height: | Size: 774 KiB |
|
After Width: | Height: | Size: 1.2 MiB |
@@ -0,0 +1,172 @@
|
||||
{
|
||||
"name": "LeeSoriDance",
|
||||
"status": "leesori_solo3_dance_pose_parts_2026_07_04",
|
||||
"canvas": {
|
||||
"width": 773,
|
||||
"height": 1448
|
||||
},
|
||||
"imageBase": "./Images/",
|
||||
"note": "Solo Dance 3 inspired pose-part rig. Parts are broad overlapping masks for the CSS Live2DHost dance loop.",
|
||||
"bones": [
|
||||
{
|
||||
"name": "base",
|
||||
"parent": null,
|
||||
"pivot": [
|
||||
386.5,
|
||||
839.8399999999999
|
||||
],
|
||||
"z": 0,
|
||||
"image": "solo3_base.png"
|
||||
},
|
||||
{
|
||||
"name": "pelvis",
|
||||
"parent": "base",
|
||||
"pivot": [
|
||||
386.5,
|
||||
695.04
|
||||
],
|
||||
"z": 2,
|
||||
"image": "solo3_pelvis.png"
|
||||
},
|
||||
{
|
||||
"name": "chest",
|
||||
"parent": "base",
|
||||
"pivot": [
|
||||
386.5,
|
||||
448.88
|
||||
],
|
||||
"z": 4,
|
||||
"image": "solo3_chest.png"
|
||||
},
|
||||
{
|
||||
"name": "upperarm_l",
|
||||
"parent": "chest",
|
||||
"pivot": [
|
||||
231.89999999999998,
|
||||
390.96000000000004
|
||||
],
|
||||
"z": 5,
|
||||
"image": "solo3_upperarm_l.png"
|
||||
},
|
||||
{
|
||||
"name": "forearm_l",
|
||||
"parent": "upperarm_l",
|
||||
"pivot": [
|
||||
139.14,
|
||||
622.64
|
||||
],
|
||||
"z": 6,
|
||||
"image": "solo3_forearm_l.png"
|
||||
},
|
||||
{
|
||||
"name": "hand_l",
|
||||
"parent": "forearm_l",
|
||||
"pivot": [
|
||||
77.30000000000001,
|
||||
868.8
|
||||
],
|
||||
"z": 7,
|
||||
"image": "solo3_hand_l.png"
|
||||
},
|
||||
{
|
||||
"name": "upperarm_r",
|
||||
"parent": "chest",
|
||||
"pivot": [
|
||||
541.0999999999999,
|
||||
390.96000000000004
|
||||
],
|
||||
"z": 5,
|
||||
"image": "solo3_upperarm_r.png"
|
||||
},
|
||||
{
|
||||
"name": "forearm_r",
|
||||
"parent": "upperarm_r",
|
||||
"pivot": [
|
||||
633.86,
|
||||
622.64
|
||||
],
|
||||
"z": 6,
|
||||
"image": "solo3_forearm_r.png"
|
||||
},
|
||||
{
|
||||
"name": "hand_r",
|
||||
"parent": "forearm_r",
|
||||
"pivot": [
|
||||
695.7,
|
||||
868.8
|
||||
],
|
||||
"z": 7,
|
||||
"image": "solo3_hand_r.png"
|
||||
},
|
||||
{
|
||||
"name": "thigh_l",
|
||||
"parent": "pelvis",
|
||||
"pivot": [
|
||||
309.20000000000005,
|
||||
810.8800000000001
|
||||
],
|
||||
"z": 2,
|
||||
"image": "solo3_thigh_l.png"
|
||||
},
|
||||
{
|
||||
"name": "shin_l",
|
||||
"parent": "thigh_l",
|
||||
"pivot": [
|
||||
301.47,
|
||||
1071.52
|
||||
],
|
||||
"z": 2,
|
||||
"image": "solo3_shin_l.png"
|
||||
},
|
||||
{
|
||||
"name": "foot_l",
|
||||
"parent": "shin_l",
|
||||
"pivot": [
|
||||
301.47,
|
||||
1332.16
|
||||
],
|
||||
"z": 2,
|
||||
"image": "solo3_foot_l.png"
|
||||
},
|
||||
{
|
||||
"name": "thigh_r",
|
||||
"parent": "pelvis",
|
||||
"pivot": [
|
||||
463.79999999999995,
|
||||
810.8800000000001
|
||||
],
|
||||
"z": 2,
|
||||
"image": "solo3_thigh_r.png"
|
||||
},
|
||||
{
|
||||
"name": "shin_r",
|
||||
"parent": "thigh_r",
|
||||
"pivot": [
|
||||
471.53,
|
||||
1071.52
|
||||
],
|
||||
"z": 2,
|
||||
"image": "solo3_shin_r.png"
|
||||
},
|
||||
{
|
||||
"name": "foot_r",
|
||||
"parent": "shin_r",
|
||||
"pivot": [
|
||||
471.53,
|
||||
1332.16
|
||||
],
|
||||
"z": 2,
|
||||
"image": "solo3_foot_r.png"
|
||||
},
|
||||
{
|
||||
"name": "head",
|
||||
"parent": "chest",
|
||||
"pivot": [
|
||||
386.5,
|
||||
231.68
|
||||
],
|
||||
"z": 10,
|
||||
"image": "solo3_head.png"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
After Width: | Height: | Size: 1.1 MiB |
|
After Width: | Height: | Size: 1.1 MiB |
|
After Width: | Height: | Size: 1.1 MiB |
|
After Width: | Height: | Size: 1.1 MiB |
|
After Width: | Height: | Size: 1.1 MiB |
|
After Width: | Height: | Size: 1.1 MiB |
|
After Width: | Height: | Size: 1.1 MiB |
|
After Width: | Height: | Size: 1.1 MiB |
|
After Width: | Height: | Size: 1.1 MiB |
|
After Width: | Height: | Size: 674 KiB |
|
After Width: | Height: | Size: 672 KiB |
|
After Width: | Height: | Size: 122 KiB |
|
After Width: | Height: | Size: 113 KiB |
@@ -0,0 +1,92 @@
|
||||
{
|
||||
"name": "LeeSoriExtended",
|
||||
"status": "extended_full_body_overlap_puppet_v1",
|
||||
"canvas": {
|
||||
"width": 929,
|
||||
"height": 1693
|
||||
},
|
||||
"imageBase": "./Images/",
|
||||
"note": "Full-body overlap puppet from AI-extended LeeSori source to avoid wrist/lower-body clipping.",
|
||||
"bones": [
|
||||
{
|
||||
"name": "base",
|
||||
"parent": null,
|
||||
"pivot": [
|
||||
464.5,
|
||||
981.9
|
||||
],
|
||||
"z": 0,
|
||||
"image": "leesori_ext_base.png"
|
||||
},
|
||||
{
|
||||
"name": "legs",
|
||||
"parent": "base",
|
||||
"pivot": [
|
||||
464.5,
|
||||
778.8
|
||||
],
|
||||
"z": 1,
|
||||
"image": "leesori_ext_legs.png"
|
||||
},
|
||||
{
|
||||
"name": "chest",
|
||||
"parent": "base",
|
||||
"pivot": [
|
||||
464.5,
|
||||
575.6
|
||||
],
|
||||
"z": 3,
|
||||
"image": "leesori_ext_chest.png"
|
||||
},
|
||||
{
|
||||
"name": "upperarm_l",
|
||||
"parent": "chest",
|
||||
"pivot": [
|
||||
288.0,
|
||||
474.0
|
||||
],
|
||||
"z": 4,
|
||||
"image": "leesori_ext_arm_l.png"
|
||||
},
|
||||
{
|
||||
"name": "upperarm_r",
|
||||
"parent": "chest",
|
||||
"pivot": [
|
||||
641.0,
|
||||
524.8
|
||||
],
|
||||
"z": 4,
|
||||
"image": "leesori_ext_arm_r.png"
|
||||
},
|
||||
{
|
||||
"name": "hand_l",
|
||||
"parent": "upperarm_l",
|
||||
"pivot": [
|
||||
362.3,
|
||||
457.1
|
||||
],
|
||||
"z": 8,
|
||||
"image": "leesori_ext_hand_l.png"
|
||||
},
|
||||
{
|
||||
"name": "hand_r",
|
||||
"parent": "upperarm_r",
|
||||
"pivot": [
|
||||
706.0,
|
||||
880.4
|
||||
],
|
||||
"z": 8,
|
||||
"image": "leesori_ext_hand_r.png"
|
||||
},
|
||||
{
|
||||
"name": "head",
|
||||
"parent": "chest",
|
||||
"pivot": [
|
||||
464.5,
|
||||
389.4
|
||||
],
|
||||
"z": 10,
|
||||
"image": "leesori_ext_head.png"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
After Width: | Height: | Size: 1.3 MiB |
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"name": "LeeSoriRegenerated",
|
||||
"status": "leesori_regenerated_idle_armscross_2026_07_04",
|
||||
"canvas": {
|
||||
"width": 1024,
|
||||
"height": 1536
|
||||
},
|
||||
"imageBase": "./Images/",
|
||||
"note": "Single-master LeeSori idle arms-cross waiting pose for DansoriEQ right-side host. Framed head-to-upper-thigh with subtle base idle animation.",
|
||||
"bones": [
|
||||
{
|
||||
"name": "base",
|
||||
"parent": null,
|
||||
"pivot": [
|
||||
512,
|
||||
610
|
||||
],
|
||||
"z": 0,
|
||||
"image": "leesori_regenerated_master.png"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
After Width: | Height: | Size: 1.5 MiB |
|
After Width: | Height: | Size: 1.5 MiB |
|
After Width: | Height: | Size: 1.5 MiB |
|
After Width: | Height: | Size: 1.5 MiB |
|
After Width: | Height: | Size: 1.5 MiB |
|
After Width: | Height: | Size: 1.5 MiB |
|
After Width: | Height: | Size: 1.5 MiB |
|
After Width: | Height: | Size: 1.5 MiB |
|
After Width: | Height: | Size: 1.5 MiB |
|
After Width: | Height: | Size: 1.1 MiB |
|
After Width: | Height: | Size: 195 KiB |
|
After Width: | Height: | Size: 209 KiB |
@@ -0,0 +1,82 @@
|
||||
{
|
||||
"name": "LeeSoriUpper",
|
||||
"status": "approved_portrait_ratio_overlap_puppet_v2",
|
||||
"canvas": {
|
||||
"width": 1086,
|
||||
"height": 1448
|
||||
},
|
||||
"imageBase": "./Images/",
|
||||
"note": "Overlap-based upper-body puppet sliced from approved clean LeeSori portrait. Base layer preserves proportions; overlays provide part motion without visible seams.",
|
||||
"bones": [
|
||||
{
|
||||
"name": "base",
|
||||
"parent": null,
|
||||
"pivot": [
|
||||
545,
|
||||
980
|
||||
],
|
||||
"z": 0,
|
||||
"image": "sori_upper_base.png"
|
||||
},
|
||||
{
|
||||
"name": "chest",
|
||||
"parent": "base",
|
||||
"pivot": [
|
||||
545,
|
||||
650
|
||||
],
|
||||
"z": 2,
|
||||
"image": "sori_upper_chest_overlay.png"
|
||||
},
|
||||
{
|
||||
"name": "upperarm_l",
|
||||
"parent": "chest",
|
||||
"pivot": [
|
||||
360,
|
||||
585
|
||||
],
|
||||
"z": 4,
|
||||
"image": "sori_upper_arm_l.png"
|
||||
},
|
||||
{
|
||||
"name": "upperarm_r",
|
||||
"parent": "chest",
|
||||
"pivot": [
|
||||
765,
|
||||
585
|
||||
],
|
||||
"z": 4,
|
||||
"image": "sori_upper_arm_r.png"
|
||||
},
|
||||
{
|
||||
"name": "hand_l",
|
||||
"parent": "upperarm_l",
|
||||
"pivot": [
|
||||
430,
|
||||
610
|
||||
],
|
||||
"z": 8,
|
||||
"image": "sori_upper_hand_l.png"
|
||||
},
|
||||
{
|
||||
"name": "hand_r",
|
||||
"parent": "upperarm_r",
|
||||
"pivot": [
|
||||
825,
|
||||
1320
|
||||
],
|
||||
"z": 8,
|
||||
"image": "sori_upper_hand_r.png"
|
||||
},
|
||||
{
|
||||
"name": "head",
|
||||
"parent": "chest",
|
||||
"pivot": [
|
||||
548,
|
||||
430
|
||||
],
|
||||
"z": 10,
|
||||
"image": "sori_upper_head.png"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
After Width: | Height: | Size: 323 KiB |
|
After Width: | Height: | Size: 321 KiB |
|
After Width: | Height: | Size: 331 KiB |
|
After Width: | Height: | Size: 324 KiB |
|
After Width: | Height: | Size: 320 KiB |
|
After Width: | Height: | Size: 318 KiB |