Files
AX-Copilot-Codex/src/AxCopilot/Handlers/AspectHandler.cs
lacvet 0336904258 AX Commander 비교본 런처 기능 대량 이식
변경 목적: Agent Compare 아래 비교본의 개발 문서와 런처 소스를 기준으로 현재 AX Commander에 빠져 있던 신규 런처 기능을 동일한 흐름으로 옮겨, 비교본 수준의 기능 폭을 현재 제품에 반영했습니다.

핵심 수정사항: 비교본의 신규 런처 핸들러 다수를 src/AxCopilot/Handlers로 이식하고 App.xaml.cs 등록 흐름에 연결했습니다. 빠른 링크, 파일 태그, 알림 센터, 포모도로, 파일 브라우저, 핫키 관리, OCR, 세션/스케줄/매크로, Git/정규식/네트워크/압축/해시/UUID/JWT/QR 등 AX Commander 기능을 추가했습니다.

핵심 수정사항: 신규 기능이 실제 동작하도록 AppSettings 확장, SchedulerService/FileTagService/NotificationCenterService/IconCacheService/UrlTemplateEngine/PomodoroService 추가, 배치 이름변경/세션/스케줄/매크로 편집 창 추가, NotificationService와 Symbols 보강, QR/OCR용 csproj 의존성과 Windows 타겟 프레임워크를 반영했습니다.

문서 반영: README.md와 docs/DEVELOPMENT.md에 비교본 기반 런처 기능 이식 이력과 검증 결과를 업데이트했습니다.

검증 결과: dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify\\ -p:IntermediateOutputPath=obj\\verify\\ 실행 기준 경고 0개, 오류 0개를 확인했습니다.
2026-04-05 00:59:45 +09:00

298 lines
13 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using System.Windows;
using AxCopilot.SDK;
using AxCopilot.Services;
using AxCopilot.Themes;
namespace AxCopilot.Handlers;
/// <summary>
/// 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 → 해상도 복사.
/// </summary>
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<IEnumerable<LauncherItem>> GetItemsAsync(string query, CancellationToken ct)
{
var q = query.Trim();
var items = new List<LauncherItem>();
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<IEnumerable<LauncherItem>>(items);
}
var parts = q.Split(' ', StringSplitOptions.RemoveEmptyEntries);
// aspect <W> <H> → 비율 계산
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<IEnumerable<LauncherItem>>(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<IEnumerable<LauncherItem>>(items);
}
}
// aspect 16:9 → 주요 해상도 목록
items.AddRange(BuildFromRatio(rw, rh));
return Task.FromResult<IEnumerable<LauncherItem>>(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<IEnumerable<LauncherItem>>(items);
}
}
items.Add(new LauncherItem("형식 오류",
"예: aspect 1920 1080 / aspect 16:9 1280 / aspect 16:9 h 720",
null, null, Symbol: "\uE783"));
return Task.FromResult<IEnumerable<LauncherItem>>(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<LauncherItem> BuildFromResolution(int w, int h)
{
var items = new List<LauncherItem>();
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<LauncherItem> BuildFromRatio(int rw, int rh)
{
var items = new List<LauncherItem>();
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<LauncherItem> BuildFromRatioAndDim(int rw, int rh, int w, int h)
{
var items = new List<LauncherItem>();
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<LauncherItem> BuildCropItems(int srcW, int srcH, int cRw, int cRh)
{
var items = new List<LauncherItem>();
// 크롭 방향 결정
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)";
}
}