using System.Windows; using AxCopilot.SDK; using AxCopilot.Services; using AxCopilot.Themes; namespace AxCopilot.Handlers; /// /// L18-3: 화면 비율·해상도 계산기 핸들러. "aspect" 프리픽스로 사용합니다. /// /// 예: aspect → 주요 비율 목록 /// aspect 1920 1080 → 1920x1080 비율 계산 /// aspect 16:9 1280 → 16:9 비율에서 너비 1280의 높이 /// aspect 16:9 h 720 → 16:9 비율에서 높이 720의 너비 /// aspect 4:3 → 4:3 비율의 주요 해상도 목록 /// aspect crop 1920 1080 4:3 → 크롭 영역 계산 /// Enter → 해상도 복사. /// public class AspectHandler : IActionHandler { public string? Prefix => "aspect"; public PluginMetadata Metadata => new( "Aspect", "화면 비율·해상도 계산기 — 16:9 · 4:3 · 21:9 · 크롭 영역", "1.0", "AX"); private record AspectPreset(string Ratio, string Name, (int W, int H)[] Resolutions); private static readonly AspectPreset[] Presets = [ new("16:9", "와이드스크린 (모니터·TV·유튜브)", [(3840,2160),(2560,1440),(1920,1080),(1600,900),(1366,768),(1280,720),(960,540),(854,480),(640,360)]), new("4:3", "전통 CRT·클래식 TV", [(2048,1536),(1600,1200),(1400,1050),(1280,960),(1024,768),(800,600),(640,480)]), new("21:9", "울트라와이드 시네마", [(5120,2160),(3440,1440),(2560,1080),(2560,1080),(1280,540)]), new("1:1", "정사각형 (인스타그램·SNS)", [(4096,4096),(2048,2048),(1080,1080),(720,720),(512,512)]), new("9:16", "세로 (모바일·스토리·릴스)", [(1080,1920),(720,1280),(540,960),(360,640)]), new("3:2", "DSLR 카메라 (35mm)", [(6000,4000),(4500,3000),(3000,2000),(1500,1000)]), new("2:1", "시네마 와이드", [(4096,2048),(2048,1024),(1920,960)]), new("5:4", "구형 모니터", [(1280,1024),(1024,819)]), new("2.35:1","영화 시네마스코프", [(2560,1090),(1920,817),(1280,544)]), ]; public Task> GetItemsAsync(string query, CancellationToken ct) { var q = query.Trim(); var items = new List(); if (string.IsNullOrWhiteSpace(q)) { items.Add(new LauncherItem("화면 비율·해상도 계산기", "예: aspect 1920 1080 / aspect 16:9 1280 / aspect 4:3", null, null, Symbol: "\uE7F4")); items.Add(new LauncherItem("── 주요 비율 ──", "", null, null, Symbol: "\uE7F4")); foreach (var p in Presets) items.Add(new LauncherItem($"{p.Ratio} {p.Name}", $"주요 해상도: {string.Join(", ", p.Resolutions.Take(3).Select(r => $"{r.W}×{r.H}"))}…", null, null, Symbol: "\uE7F4")); return Task.FromResult>(items); } var parts = q.Split(' ', StringSplitOptions.RemoveEmptyEntries); // aspect → 비율 계산 if (parts.Length >= 2 && int.TryParse(parts[0], out var w1) && int.TryParse(parts[1], out var h1)) { items.AddRange(BuildFromResolution(w1, h1)); return Task.FromResult>(items); } // aspect <비율> 형식 (예: 16:9, 4:3, 16/9) if (TryParseRatio(parts[0], out var rw, out var rh)) { // aspect 16:9 <너비> 또는 aspect 16:9 h <높이> if (parts.Length >= 2) { var isHeight = parts.Length >= 3 && parts[1].ToLowerInvariant() is "h" or "height" or "높이"; var dimStr = isHeight ? parts[2] : parts[1]; if (int.TryParse(dimStr, out var dim)) { if (isHeight) { var calcW = (int)Math.Round((double)dim * rw / rh); items.AddRange(BuildFromRatioAndDim(rw, rh, calcW, dim)); } else { var calcH = (int)Math.Round((double)dim * rh / rw); items.AddRange(BuildFromRatioAndDim(rw, rh, dim, calcH)); } return Task.FromResult>(items); } } // aspect 16:9 → 주요 해상도 목록 items.AddRange(BuildFromRatio(rw, rh)); return Task.FromResult>(items); } // crop 서브커맨드 if (parts[0].ToLowerInvariant() == "crop" && parts.Length >= 5) { if (int.TryParse(parts[1], out var srcW) && int.TryParse(parts[2], out var srcH) && TryParseRatio(parts[3], out var cRw, out var cRh)) { items.AddRange(BuildCropItems(srcW, srcH, cRw, cRh)); return Task.FromResult>(items); } } items.Add(new LauncherItem("형식 오류", "예: aspect 1920 1080 / aspect 16:9 1280 / aspect 16:9 h 720", null, null, Symbol: "\uE783")); return Task.FromResult>(items); } public Task ExecuteAsync(LauncherItem item, CancellationToken ct) { if (item.Data is ("copy", string text)) { try { System.Windows.Application.Current.Dispatcher.Invoke( () => Clipboard.SetText(text)); NotificationService.Notify("Aspect", "클립보드에 복사했습니다."); } catch { } } return Task.CompletedTask; } // ── 빌더 ───────────────────────────────────────────────────────────────── private static List BuildFromResolution(int w, int h) { var items = new List(); var gcd = Gcd(w, h); var rw = w / gcd; var rh = h / gcd; var ratio = $"{rw}:{rh}"; var frac = (double)w / h; items.Add(new LauncherItem($"{w} × {h} → 비율 {ratio}", $"소수 비율: {frac:F4}", null, ("copy", ratio), Symbol: "\uE7F4")); items.Add(new LauncherItem($"비율", ratio, null, ("copy", ratio), Symbol: "\uE7F4")); items.Add(new LauncherItem($"소수 비율", $"{frac:F4}", null, ("copy", $"{frac:F4}"), Symbol: "\uE7F4")); items.Add(new LauncherItem($"픽셀 수", $"{(long)w * h:N0} px ({(long)w * h / 1_000_000.0:F1} MP)", null, null, Symbol: "\uE7F4")); // 비슷한 프리셋 찾기 var preset = Presets.FirstOrDefault(p => p.Ratio == ratio); if (preset != null) { items.Add(new LauncherItem($"── {preset.Ratio} {preset.Name} ──", "", null, null, Symbol: "\uE7F4")); foreach (var (pw, ph) in preset.Resolutions) items.Add(new LauncherItem($"{pw} × {ph}", FormatPixels(pw, ph), null, ("copy", $"{pw}x{ph}"), Symbol: "\uE7F4")); } else { // 같은 비율의 다른 해상도 계산 items.Add(new LauncherItem("── 같은 비율 기타 해상도 ──", "", null, null, Symbol: "\uE7F4")); var scales = new[] { 0.25, 0.5, 0.75, 1.25, 1.5, 2.0 }; foreach (var s in scales) { var sw = (int)Math.Round(w * s / gcd) * gcd; var sh = (int)Math.Round(h * s / gcd) * gcd; if (sw > 0 && sh > 0) items.Add(new LauncherItem($"{sw} × {sh} ({s:P0})", FormatPixels(sw, sh), null, ("copy", $"{sw}x{sh}"), Symbol: "\uE7F4")); } } return items; } private static List BuildFromRatio(int rw, int rh) { var items = new List(); var preset = Presets.FirstOrDefault(p => p.Ratio == $"{rw}:{rh}"); if (preset != null) { items.Add(new LauncherItem($"{preset.Ratio} {preset.Name}", $"주요 해상도 {preset.Resolutions.Length}개", null, null, Symbol: "\uE7F4")); foreach (var (pw, ph) in preset.Resolutions) items.Add(new LauncherItem($"{pw} × {ph}", FormatPixels(pw, ph), null, ("copy", $"{pw}x{ph}"), Symbol: "\uE7F4")); } else { items.Add(new LauncherItem($"{rw}:{rh} 비율", "자주 쓰는 너비 기준 해상도", null, null, Symbol: "\uE7F4")); var widths = new[] { 640, 1280, 1920, 2560, 3840 }; foreach (var bw in widths) { var bh = (int)Math.Round((double)bw * rh / rw); items.Add(new LauncherItem($"{bw} × {bh}", FormatPixels(bw, bh), null, ("copy", $"{bw}x{bh}"), Symbol: "\uE7F4")); } } return items; } private static List BuildFromRatioAndDim(int rw, int rh, int w, int h) { var items = new List(); var label = $"{rw}:{rh} → {w} × {h}"; items.Add(new LauncherItem(label, $"픽셀 {(long)w * h:N0} · Enter 복사", null, ("copy", $"{w}x{h}"), Symbol: "\uE7F4")); items.Add(new LauncherItem($"너비", $"{w} px", null, ("copy", $"{w}"), Symbol: "\uE7F4")); items.Add(new LauncherItem($"높이", $"{h} px", null, ("copy", $"{h}"), Symbol: "\uE7F4")); items.Add(new LauncherItem("CSS", $"{w}px × {h}px", null, ("copy", $"{w}px × {h}px"), Symbol: "\uE7F4")); items.Add(new LauncherItem("w×h", $"{w}x{h}", null, ("copy", $"{w}x{h}"), Symbol: "\uE7F4")); return items; } private static List BuildCropItems(int srcW, int srcH, int cRw, int cRh) { var items = new List(); // 크롭 방향 결정 var srcRatio = (double)srcW / srcH; var cropRatio = (double)cRw / cRh; int cropW, cropH, offsetX, offsetY; if (srcRatio > cropRatio) { // 좌우 크롭 cropH = srcH; cropW = (int)Math.Round(srcH * cropRatio); offsetX = (srcW - cropW) / 2; offsetY = 0; } else { // 상하 크롭 cropW = srcW; cropH = (int)Math.Round(srcW / cropRatio); offsetX = 0; offsetY = (srcH - cropH) / 2; } items.Add(new LauncherItem($"크롭: {srcW}×{srcH} → {cRw}:{cRh}", $"크롭 영역: {cropW}×{cropH} 오프셋: ({offsetX},{offsetY})", null, ("copy", $"{cropW}x{cropH}"), Symbol: "\uE7F4")); items.Add(new LauncherItem("크롭 크기", $"{cropW} × {cropH}", null, ("copy", $"{cropW}x{cropH}"), Symbol: "\uE7F4")); items.Add(new LauncherItem("X 오프셋", $"{offsetX} px", null, ("copy", $"{offsetX}"), Symbol: "\uE7F4")); items.Add(new LauncherItem("Y 오프셋", $"{offsetY} px", null, ("copy", $"{offsetY}"), Symbol: "\uE7F4")); items.Add(new LauncherItem("FFmpeg crop", $"crop={cropW}:{cropH}:{offsetX}:{offsetY}", null, ("copy", $"crop={cropW}:{cropH}:{offsetX}:{offsetY}"), Symbol: "\uE7F4")); return items; } // ── 헬퍼 ───────────────────────────────────────────────────────────────── private static bool TryParseRatio(string s, out int rw, out int rh) { rw = rh = 0; var sep = s.Contains(':') ? ':' : s.Contains('/') ? '/' : '\0'; if (sep == '\0') return false; var parts = s.Split(sep); if (parts.Length != 2) return false; return double.TryParse(parts[0], System.Globalization.NumberStyles.Any, System.Globalization.CultureInfo.InvariantCulture, out var drw) && double.TryParse(parts[1], System.Globalization.NumberStyles.Any, System.Globalization.CultureInfo.InvariantCulture, out var drh) && (rw = (int)Math.Round(drw * 100)) > 0 && (rh = (int)Math.Round(drh * 100)) > 0; } private static int Gcd(int a, int b) => b == 0 ? a : Gcd(b, a % b); private static string FormatPixels(int w, int h) { var mp = (long)w * h / 1_000_000.0; return $"{(long)w * h:N0} px ({mp:F1} MP)"; } }