using System.Collections.ObjectModel; using System.ComponentModel; using System.Globalization; using System.Runtime.CompilerServices; using System.Windows; using System.Windows.Controls; using System.Windows.Controls.Primitives; using System.Windows.Input; using System.Windows.Media; using System.Windows.Media.Imaging; using Everything2Everything.App.Shell; using Everything2Everything.App.ViewModels; using Everything2Everything.Core; using Everything2Everything.Core.Filters; using Everything2Everything.Core.Inspector; using Everything2Everything.Core.Presets; using LossClass = Everything2Everything.Core.Providers.LossClass; namespace Everything2Everything.App.Views; public partial class MainWindow : Wpf.Ui.Controls.FluentWindow, INotifyPropertyChanged { public event PropertyChangedEventHandler? PropertyChanged; private void OnPropertyChanged([CallerMemberName] string? name = null) => PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(name)); private bool _hasSelectedItem; public bool HasSelectedItem { get => _hasSelectedItem; set { if (_hasSelectedItem != value) { _hasSelectedItem = value; OnPropertyChanged(); } } } public ICommand ClearSearchCommand { get; } public ICommand SelectAllQueueCommand => BatchSelectAllCommand; public ICommand DeleteSelectedQueueCommand => BatchRemoveSelectedCommand; private readonly ConversionEngine _engine; private readonly ISettingsStore _settings; private readonly OptionsViewModel _options = new(); private readonly ObservableCollection _activeQueue = new(); private readonly ObservableCollection _pastResults = new(); private CancellationTokenSource? _cts; private NameCollision _conflictRule = NameCollision.AppendNumber; public string? SelectedOutputExtension { get; set; } = ".jpg"; /// 옵션 패널 XAML이 TwoWay 바인딩하는 옵션 뷰모델(품질·출력폴더). public OptionsViewModel Options => _options; /// Active Queue / Past Results 리스트가 XAML에서 ItemsSource로 바인딩하는 컬렉션. public ObservableCollection ActiveQueue => _activeQueue; public ObservableCollection PastResults => _pastResults; public ICommand AddFilesCommand { get; } public ICommand ProcessQueueCommand { get; } public ICommand CloseCommand { get; } public ICommand RefreshCommand { get; } // 상단바·액션 버튼 커맨드 (P5b: Click 핸들러 → 커맨드 바인딩) public ICommand SettingsCommand { get; } public ICommand RegisterCommand { get; } public ICommand DiagnoseCommand { get; } public ICommand ExportLogCommand { get; } public ICommand ClearAllCommand { get; } public ICommand PickOutputFolderCommand { get; } public ICommand CancelProcessingCommand { get; } public ICommand PreviewOpenFolderCommand { get; } public ICommand PreviewOpenFileCommand { get; } public ICommand RemoveQueueItemCommand { get; } public ICommand OpenFolderCommand { get; } public ICommand TabCommand { get; } public ICommand ConflictRuleCommand { get; } public ICommand CombineToggleCommand { get; } public ICommand OutputFormatChangedCommand { get; } public ICommand DragOverCommand { get; } public ICommand DragLeaveCommand { get; } public ICommand DropCommand { get; } public ICommand QueueRowCommand { get; } public ICommand PastRowCommand { get; } // 신규 프리셋, 필터, 일괄 작업 커맨드 public ICommand PresetCommand { get; } public ICommand FilterCategoryCommand { get; } public ICommand BatchSelectAllCommand { get; } public ICommand BatchRemoveSelectedCommand { get; } public ICommand BatchClearCompletedCommand { get; } public ICommand ToggleInspectorCommand { get; } private bool _isInspectorVisible = true; public bool IsInspectorVisible { get => _isInspectorVisible; set { if (_isInspectorVisible != value) { _isInspectorVisible = value; if (InspectorColumn != null) { InspectorColumn.Width = _isInspectorVisible ? new GridLength(380) : new GridLength(0); } } } } private string _searchText = ""; public string SearchText { get => _searchText; set { if (_searchText != value) { _searchText = value; ApplyQueueFilters(); } } } private FilterCategory _selectedCategory = FilterCategory.All; public FilterCategory SelectedCategory { get => _selectedCategory; set { if (_selectedCategory != value) { _selectedCategory = value; ApplyQueueFilters(); } } } public MainWindow(ConversionEngine engine, ISettingsStore settings, IReadOnlyList? initialFiles = null) { _engine = engine; _settings = settings; AddFilesCommand = new RelayCommand(_ => PickAndAddFiles()); ProcessQueueCommand = new RelayCommand(_ => OnProcessQueueClick(this, new RoutedEventArgs()), _ => _activeQueue.Count > 0 && _cts is null); CloseCommand = new RelayCommand(_ => Close()); RefreshCommand = new RelayCommand(_ => ApplyAppDataStats()); SettingsCommand = new RelayCommand(_ => OnSettingsClick(this, new RoutedEventArgs())); RegisterCommand = new RelayCommand(_ => OnRegisterClick(this, new RoutedEventArgs())); DiagnoseCommand = new RelayCommand(_ => OnDiagnoseClick(this, new RoutedEventArgs())); ExportLogCommand = new RelayCommand(_ => OnExportLogClick(this, new RoutedEventArgs())); ClearAllCommand = new RelayCommand(_ => OnClearAllClick(this, new RoutedEventArgs())); PickOutputFolderCommand = new RelayCommand(_ => OnPickOutputFolderClick(this, new RoutedEventArgs())); CancelProcessingCommand = new RelayCommand(_ => OnCancelProcessingClick(this, new RoutedEventArgs())); PreviewOpenFolderCommand = new RelayCommand(_ => OnPreviewOpenFolder(this, new RoutedEventArgs())); PreviewOpenFileCommand = new RelayCommand(_ => OnPreviewOpenFile(this, new RoutedEventArgs())); PresetCommand = new RelayCommand(p => ApplyPreset(p?.ToString())); FilterCategoryCommand = new RelayCommand(p => ApplyFilterCategory(p?.ToString())); BatchSelectAllCommand = new RelayCommand(p => BatchSelectAll(p)); BatchRemoveSelectedCommand = new RelayCommand(_ => BatchRemoveSelected()); BatchClearCompletedCommand = new RelayCommand(_ => BatchClearCompleted()); ToggleInspectorCommand = new RelayCommand(_ => ToggleInspector()); RemoveQueueItemCommand = new RelayCommand(p => RemoveQueueItem(p as QueueItem)); OpenFolderCommand = new RelayCommand(p => OpenFolderForPath(p as string)); TabCommand = new RelayCommand(p => ShowTab(p as string ?? "Active")); ConflictRuleCommand = new RelayCommand(p => SetConflictRule(p as string)); CombineToggleCommand = new RelayCommand(_ => UpdateCombineState(SelectedOutputExtension)); OutputFormatChangedCommand = new RelayCommand(_ => OnOutputFormatSelected()); DragOverCommand = new RelayCommand(p => HandleDragOver(p as DragEventArgs)); DragLeaveCommand = new RelayCommand(_ => DropHintOverlay.Visibility = Visibility.Collapsed); DropCommand = new RelayCommand(p => HandleFilesDropped(p as DragEventArgs)); QueueRowCommand = new RelayCommand(p => HandleQueueRowClick(p as MouseButtonEventArgs)); ClearSearchCommand = new RelayCommand(_ => { SearchText = ""; if (SearchBox is not null) SearchBox.Text = ""; }); PastRowCommand = new RelayCommand(p => HandlePastRowClick(p as MouseButtonEventArgs)); InitializeComponent(); Title = "Everything2Everything"; if (AppTitleBar is not null) AppTitleBar.Title = "Everything2Everything"; if (AdvancedOptionsExpander is not null) { AdvancedOptionsExpander.IsExpanded = true; AdvancedOptionsExpander.Expanded += (_, _) => _options.IsAdvancedExpanded = true; AdvancedOptionsExpander.Collapsed += (_, _) => _options.IsAdvancedExpanded = false; } if (SmartPresetCombo is not null) { SmartPresetCombo.SelectionChanged += OnSmartPresetChanged; } if (OutputFormatCombo is not null) { OutputFormatCombo.SelectionChanged += (_, _) => OnOutputFormatSelected(); } // ActiveQueueList/PastResultsList의 ItemsSource는 XAML이 ActiveQueue/PastResults에 바인딩(선언적). InitializeOutputFormats(); LoadHistory(); UpdateBadges(); UpdateProcessQueueButton(); if (initialFiles is { Count: > 0 }) { AddToQueue(initialFiles); } ShowTab("Active"); UpdateActiveQueueVisibility(); ApplyAppDataStats(); _ = RefreshCapabilityStatusAsync(); } private async Task RefreshCapabilityStatusAsync() { var engine = _engine; var notReady = new List(); foreach (var p in engine.Providers.All) { if (p.Capability.Status == Everything2Everything.Core.Providers.ProviderStatus.RequiresExternal) { var availability = await p.CheckAvailabilityAsync(); if (!availability.IsReady) notReady.Add(p.Capability.DisplayName); } } if (notReady.Count == 0) { CapabilityStatusText.Visibility = Visibility.Collapsed; return; } CapabilityStatusText.Text = $"⚠ {notReady.Count}개 형식이 외부 도구를 기다립니다 (진단 도구 참조)"; CapabilityStatusText.Visibility = Visibility.Visible; } private void OnSettingsClick(object sender, RoutedEventArgs e) { var win = new SettingsWindow(_settings) { Owner = this }; win.ShowDialog(); _ = RefreshCapabilityStatusAsync(); RefreshAvailableOutputFormats(); } private void PickAndAddFiles() { var dlg = new Microsoft.Win32.OpenFileDialog { Multiselect = true, Title = "변환할 파일 추가", Filter = "지원 파일|*.png;*.jpg;*.jpeg;*.gif;*.bmp;*.tif;*.tiff;*.webp;*.avif;*.heic;*.heif;*.psd;*.dng;*.nef;*.cr2;*.cr3;*.arw;*.raf;*.orf;*.rw2;*.srw;*.pef;*.pdf;*.docx;*.doc;*.html;*.htm;*.hwp;*.hwpx|모든 파일|*.*", }; if (dlg.ShowDialog(this) == true) { AddToQueue(dlg.FileNames); ShowTab("Active"); } } // ============== Tabs ============== private void ShowTab(string tag) { TabActiveBtn.IsChecked = tag == "Active"; TabPastBtn.IsChecked = tag == "Past"; ActiveQueueView.Visibility = tag == "Active" ? Visibility.Visible : Visibility.Collapsed; PastResultsContainer.Visibility = tag == "Past" ? Visibility.Visible : Visibility.Collapsed; UpdatePastResultsVisibility(); } public void ToggleInspector() { IsInspectorVisible = !IsInspectorVisible; } // ============== Drag & Drop ============== private void HandleDragOver(DragEventArgs? e) { if (e is null) return; e.Effects = e.Data.GetDataPresent(DataFormats.FileDrop) ? DragDropEffects.Copy : DragDropEffects.None; DropHintOverlay.Visibility = e.Effects == DragDropEffects.Copy ? Visibility.Visible : Visibility.Collapsed; e.Handled = true; } private void HandleFilesDropped(DragEventArgs? e) { DropHintOverlay.Visibility = Visibility.Collapsed; if (e is null || !e.Data.GetDataPresent(DataFormats.FileDrop)) return; if (e.Data.GetData(DataFormats.FileDrop) is not string[] paths) return; AddToQueue(ExpandPaths(paths)); ShowTab("Active"); } private static IEnumerable ExpandPaths(IEnumerable paths) { foreach (var p in paths) { if (File.Exists(p)) yield return p; else if (Directory.Exists(p)) { foreach (var f in Directory.EnumerateFiles(p, "*", SearchOption.TopDirectoryOnly)) yield return f; } } } private void AddToQueue(IEnumerable paths) { var wasEmpty = _activeQueue.Count == 0; var existing = new HashSet(_activeQueue.Select(q => q.SourcePath), StringComparer.OrdinalIgnoreCase); foreach (var path in paths) { if (!File.Exists(path) || existing.Contains(path)) continue; var item = QueueItem.FromPath(path); item.PropertyChanged += OnQueueItemPropertyChanged; _activeQueue.Add(item); } UpdateBadges(); UpdateProcessQueueButton(); UpdateActiveQueueVisibility(); RefreshAvailableOutputFormats(); if (wasEmpty && _activeQueue.Count > 0 && _selectedPreviewItem is null) { _selectedPreviewItem = _activeQueue[0]; _ = LoadPreviewAsync(_selectedPreviewItem); } } private void OnQueueItemPropertyChanged(object? sender, PropertyChangedEventArgs e) { if (e.PropertyName == nameof(QueueItem.IsSelected)) { UpdateQueueSummary(); } } private void RemoveQueueItem(QueueItem? item) { if (item is null) return; item.PropertyChanged -= OnQueueItemPropertyChanged; _activeQueue.Remove(item); UpdateBadges(); UpdateProcessQueueButton(); UpdateActiveQueueVisibility(); RefreshAvailableOutputFormats(); } private void UpdateActiveQueueVisibility() { var hasItems = _activeQueue.Count > 0; DropZoneEmpty.Visibility = hasItems ? Visibility.Collapsed : Visibility.Visible; ActiveQueueScroll.Visibility = hasItems ? Visibility.Visible : Visibility.Collapsed; if (FindName("BatchActionBar") is UIElement batchBar) { batchBar.Visibility = hasItems ? Visibility.Visible : Visibility.Collapsed; } } private void UpdatePastResultsVisibility() { var hasItems = _pastResults.Count > 0; PastResultsEmpty.Visibility = hasItems ? Visibility.Collapsed : Visibility.Visible; PastResultsView.Visibility = hasItems ? Visibility.Visible : Visibility.Collapsed; } private void UpdateBadges() { TabActiveBadge.Text = _activeQueue.Count.ToString(CultureInfo.InvariantCulture); var count = _pastResults.Sum(g => g.Entries.Count); TabPastBadge.Text = count.ToString(CultureInfo.InvariantCulture); UpdatePastResultsVisibility(); UpdateQueueSummary(); UpdatePastResultsTelemetry(); } private void UpdateQueueSummary() { if (QueueSummaryCountText is null || QueueSummarySizeText is null) return; var count = _activeQueue.Count; long totalBytes = _activeQueue.Sum(q => q.SourceSizeBytes); QueueSummaryCountText.Text = $"총 {count}개 항목"; QueueSummarySizeText.Text = HumanizeBytes(totalBytes); var selectedCount = _activeQueue.Count(q => q.IsSelected); if (QueueSelectedBadge is not null && QueueSelectedText is not null) { if (selectedCount > 0) { QueueSelectedBadge.Visibility = Visibility.Visible; QueueSelectedText.Text = $"{selectedCount}개 선택됨"; } else { QueueSelectedBadge.Visibility = Visibility.Collapsed; } } } private void UpdatePastResultsTelemetry() { if (PastTotalCountText is null || PastTotalSavingsText is null) return; var totalCount = _pastResults.Sum(g => g.Entries.Count); var totalSavings = _pastResults.Sum(g => g.SessionSavingsBytes); PastTotalCountText.Text = $"{totalCount}개 파일"; PastTotalSavingsText.Text = HumanizeBytes(totalSavings); } private void UpdateProcessQueueButton() { var count = _activeQueue.Count; if (_cts is not null) { ProcessQueueButton.Content = $"변환 처리 중… ({count}개)"; ProcessQueueButton.ToolTip = "파일 변환이 진행 중입니다."; ProcessQueueButton.IsEnabled = false; } else if (count == 0) { ProcessQueueButton.Content = "파일을 드래그하여 추가"; ProcessQueueButton.ToolTip = "대기열에 변환할 파일을 추가하세요 (단축키: Ctrl + O)"; ProcessQueueButton.IsEnabled = false; } else if (string.IsNullOrEmpty(SelectedOutputExtension)) { ProcessQueueButton.Content = "공통 형식 없음"; ProcessQueueButton.ToolTip = "선택된 파일들 간에 호환 가능한 공통 출력 형식이 없습니다."; ProcessQueueButton.IsEnabled = false; } else { ProcessQueueButton.Content = $"변환 시작 ({count}개 파일)"; ProcessQueueButton.ToolTip = $"대기열 일괄 변환 시작 ({count}개 파일) [단축키: Ctrl + Enter]"; ProcessQueueButton.IsEnabled = true; } } // ============== Sidebar inputs ============== private void SetConflictRule(string? tag) { _conflictRule = tag switch { "Skip" => NameCollision.Skip, "Replace" => NameCollision.Overwrite, _ => NameCollision.AppendNumber, }; // 세그먼트 토글 그룹의 상호배제(하나만 체크). ConflictSkipBtn.IsChecked = tag == "Skip"; ConflictRenameBtn.IsChecked = tag == "Rename"; ConflictReplaceBtn.IsChecked = tag == "Replace"; } private void OnPickOutputFolderClick(object sender, RoutedEventArgs e) { var dlg = new Microsoft.Win32.OpenFolderDialog { Title = "출력 폴더 선택" }; if (dlg.ShowDialog(this) == true) OutputPathTextBox.Text = dlg.FolderName; } private ConvertOptions BuildOptions() { // Quality/CustomOutputDirectory는 옵션 패널 XAML이 _options에 TwoWay 바인딩(선언적). // 충돌 규칙(세그먼트 토글)·AI(콤보 상호작용)·GPU(설정)는 상호작용 로직이 있어 코드비하인드에서 반영. _options.ConflictRule = _conflictRule; _options.AiTaskIndex = AiTaskCombo?.SelectedIndex ?? 0; _options.TargetLanguage = AiTargetLangBox?.Text?.Trim(); _options.VideoPreferGpu = _settings.Get("video.gpu") != "false"; return _options.ToConvertOptions(); } // ============== Process queue ============== // ============== Preview ============== private QueueItem? _selectedPreviewItem; private string? _selectedPreviewPath; private CancellationTokenSource? _previewCts; private async void HandleQueueRowClick(MouseButtonEventArgs? e) { if (e is null) return; if (e.OriginalSource is DependencyObject src && IsInsideButton(src)) return; if ((e.OriginalSource as FrameworkElement)?.DataContext is not QueueItem item) return; _selectedPreviewItem = item; await LoadPreviewAsync(item); e.Handled = true; } private async void HandlePastRowClick(MouseButtonEventArgs? e) { if (e is null) return; if (e.OriginalSource is DependencyObject src && IsInsideButton(src)) return; if ((e.OriginalSource as FrameworkElement)?.DataContext is not HistoryRow row) return; if (row.SourcePath == "") { SetPreviewMeta(row.FileName, row.SourcePath, row.FormatLabel, row.SizeText); ShowPreviewReason("샘플 데모 항목입니다. 원본 파일이 없으므로 미리보기를 만들 수 없습니다."); _selectedPreviewPath = null; } else if (!File.Exists(row.SourcePath)) { SetPreviewMeta(row.FileName, row.SourcePath, row.FormatLabel, row.SizeText); ShowPreviewReason("원본 파일을 찾을 수 없습니다. 파일이 이동·삭제되었을 수 있습니다."); _selectedPreviewPath = null; } else { _selectedPreviewItem = null; _selectedPreviewPath = row.SourcePath; await LoadPreviewByPathAsync(row.SourcePath, row.FileName, row.FormatLabel, row.SizeText); } e.Handled = true; } private static bool IsInsideButton(DependencyObject? node) { while (node is not null) { if (node is Button) return true; node = System.Windows.Media.VisualTreeHelper.GetParent(node) ?? (node is FrameworkElement fe ? fe.Parent : null); } return false; } private Task LoadPreviewAsync(QueueItem item) { _selectedPreviewPath = item.SourcePath; return LoadPreviewByPathAsync(item.SourcePath, item.FileName, item.FormatLabel, item.SizeText); } private async Task LoadPreviewByPathAsync(string sourcePath, string fileName, string formatLabel, string sizeText) { _previewCts?.Cancel(); _previewCts = new CancellationTokenSource(); var token = _previewCts.Token; SetPreviewMeta(fileName, sourcePath, formatLabel, sizeText); ShowPreviewLoading(); try { var result = await PreviewService.CreateAsync(sourcePath, 720, token); if (token.IsCancellationRequested) return; PreviewLoading.Visibility = Visibility.Collapsed; if (result.Image is not null) { PreviewImage.Source = result.Image; PreviewImage.Visibility = Visibility.Visible; } else { // 래스터 미리보기가 없는 형식(영상·오디오·문서·데이터 등)은 경고 대신 카테고리 글리프로 표현 ShowPreviewGlyph( System.IO.Path.GetExtension(sourcePath), result.Reason ?? "이 형식은 미리보기를 만들 수 없습니다. 변환은 정상 동작합니다."); } PreviewDimText.Text = result.Dimensions ?? "—"; PreviewPageText.Text = result.PageCount?.ToString() ?? "—"; } catch (OperationCanceledException) { } catch (Exception ex) { PreviewLoading.Visibility = Visibility.Collapsed; ShowPreviewReason("미리보기 오류: " + ex.Message); } } private void SetPreviewMeta(string fileName, string filePath, string formatLabel, string sizeText) { var info = FileInspectorBuilder.Build(filePath); PreviewFileName.Text = string.IsNullOrEmpty(fileName) ? info.FileName : fileName; PreviewFilePath.Text = string.IsNullOrEmpty(filePath) ? info.FullPath : filePath; var fmt = string.IsNullOrEmpty(formatLabel) || formatLabel == "—" ? info.Extension.TrimStart('.').ToUpperInvariant() : formatLabel; var sz = string.IsNullOrEmpty(sizeText) || sizeText == "—" ? info.FormattedSize : sizeText; PreviewFormatText.Text = fmt; PreviewSizeText.Text = sz; PreviewDimText.Text = info.DimensionsOrMeta; PreviewPageText.Text = info.Category.ToKoreanLabel(); UpdateConversionPipelineCard(fmt, sz); HasSelectedItem = !string.IsNullOrEmpty(filePath); } private void UpdateConversionPipelineCard(string? formatLabel, string? sizeText) { if (ConversionPipelineCard is null || PipelineSourceText is null || PipelineTargetText is null) return; if (string.IsNullOrEmpty(formatLabel) || formatLabel == "—") { PipelineSourceText.Text = "선택 대기"; PipelineTargetText.Text = (SelectedOutputExtension ?? ".jpg").TrimStart('.').ToUpperInvariant(); if (PipelineLossText is not null) PipelineLossText.Text = "파일을 선택하면 변환 정보 표시"; if (PipelineEstimatedText is not null) PipelineEstimatedText.Text = "대기 중"; return; } PipelineSourceText.Text = $"{formatLabel} · {sizeText ?? "—"}"; var targetExt = (SelectedOutputExtension ?? ".jpg").TrimStart('.').ToUpperInvariant(); PipelineTargetText.Text = targetExt; var isSame = string.Equals(formatLabel, targetExt, StringComparison.OrdinalIgnoreCase); if (isSame) { if (PipelineLossText is not null) PipelineLossText.Text = "무손실 재압축 및 메타 정돈"; if (PipelineEstimatedText is not null) PipelineEstimatedText.Text = "최적화"; } else { if (PipelineLossText is not null) PipelineLossText.Text = $"{formatLabel} → {targetExt} 변환"; if (PipelineEstimatedText is not null) PipelineEstimatedText.Text = "실시간 매칭"; } } private void ShowPreviewLoading() { PreviewEmpty.Visibility = Visibility.Collapsed; PreviewImage.Visibility = Visibility.Collapsed; PreviewReason.Visibility = Visibility.Collapsed; PreviewLoading.Visibility = Visibility.Visible; } private void ShowPreviewReason(string reason) { PreviewEmpty.Visibility = Visibility.Collapsed; PreviewImage.Visibility = Visibility.Collapsed; PreviewLoading.Visibility = Visibility.Collapsed; PreviewGlyph.Visibility = Visibility.Collapsed; PreviewWarnIcon.Visibility = Visibility.Visible; PreviewReasonText.Text = reason; PreviewReason.Visibility = Visibility.Visible; } /// 래스터 미리보기가 없는 형식은 경고 대신 카테고리 글리프로 안내한다. private void ShowPreviewGlyph(string? extension, string reason) { PreviewEmpty.Visibility = Visibility.Collapsed; PreviewImage.Visibility = Visibility.Collapsed; PreviewLoading.Visibility = Visibility.Collapsed; PreviewGlyph.Source = CategoryGlyphs.ForExtension(extension); PreviewGlyph.Visibility = Visibility.Visible; PreviewWarnIcon.Visibility = Visibility.Collapsed; PreviewReasonText.Text = reason; PreviewReason.Visibility = Visibility.Visible; } private void OnPreviewOpenFolder(object sender, RoutedEventArgs e) { var path = _selectedPreviewPath ?? _selectedPreviewItem?.SourcePath; if (string.IsNullOrEmpty(path) || !File.Exists(path)) return; try { System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo { FileName = "explorer.exe", Arguments = $"/select,\"{path}\"", UseShellExecute = true, }); } catch { } } private void OnPreviewOpenFile(object sender, RoutedEventArgs e) { var path = _selectedPreviewPath ?? _selectedPreviewItem?.SourcePath; if (string.IsNullOrEmpty(path) || !File.Exists(path)) return; try { System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo(path) { UseShellExecute = true, }); } catch { } } private void ApplyPreset(string? presetName) { if (Enum.TryParse(presetName, true, out var type)) { var firstItem = _activeQueue.FirstOrDefault()?.SourcePath; var ext = !string.IsNullOrEmpty(firstItem) ? Path.GetExtension(firstItem) : ".png"; var recommended = ConversionPreset.Apply(type, _options, ext); for (int i = 0; i < OutputFormatCombo.Items.Count; i++) { if (OutputFormatCombo.Items[i] is OutputFormatInfo info && info.Extension.Equals(recommended, StringComparison.OrdinalIgnoreCase)) { OutputFormatCombo.SelectedIndex = i; break; } } var targets = _activeQueue.Where(q => q.IsSelected).ToList(); if (targets.Count == 0) targets = _activeQueue.ToList(); foreach (var item in targets) { item.SelectedOutputExtension = recommended; } QualitySlider.Value = _options.Quality; QualityValueText.Text = _options.Quality.ToString(CultureInfo.InvariantCulture); } } private IReadOnlyList _currentFormatPresets = Array.Empty(); private bool _suppressPresetChanged; private void UpdateSmartPresetsForFormat(string? ext) { if (SmartPresetCombo is null) return; _currentFormatPresets = FormatPresetEngine.GetPresetsForExtension(ext); _suppressPresetChanged = true; try { SmartPresetCombo.Items.Clear(); foreach (var preset in _currentFormatPresets) { SmartPresetCombo.Items.Add(new ComboBoxItem { Content = preset.Title, Tag = preset.Id, ToolTip = preset.Description, }); } if (SmartPresetCombo.Items.Count > 0) { SmartPresetCombo.SelectedIndex = 0; } } finally { _suppressPresetChanged = false; } ApplySelectedSmartPreset(); } private void OnSmartPresetChanged(object sender, SelectionChangedEventArgs e) { if (_suppressPresetChanged) return; ApplySelectedSmartPreset(); } private void ApplySelectedSmartPreset() { if (SmartPresetCombo is null || SmartPresetCombo.SelectedIndex < 0) return; if (SmartPresetCombo.SelectedIndex >= _currentFormatPresets.Count) return; var preset = _currentFormatPresets[SmartPresetCombo.SelectedIndex]; preset.Apply(_options); if (SmartPresetDescriptionText is not null) { SmartPresetDescriptionText.Text = preset.Description; } if (SmartPresetChipsPanel is not null) { SmartPresetChipsPanel.Children.Clear(); var chipStyle = TryFindResource("FsSpecChipStyle") as Style; var monoFont = TryFindResource("FsFontMono") as FontFamily; var cyanBrush = TryFindResource("FsAccentCyan") as Brush ?? Brushes.Cyan; foreach (var chip in preset.SpecChips) { var border = new Border(); if (chipStyle is not null) { border.Style = chipStyle; } else { border.CornerRadius = new CornerRadius(6); border.Padding = new Thickness(8, 3, 8, 3); border.Margin = new Thickness(0, 0, 6, 6); } var tb = new TextBlock { Text = chip, FontSize = 11, Foreground = cyanBrush, VerticalAlignment = VerticalAlignment.Center, }; if (monoFont is not null) { tb.FontFamily = monoFont; } border.Child = tb; SmartPresetChipsPanel.Children.Add(border); } } if (QualitySlider is not null) { QualitySlider.Value = _options.Quality; } if (QualityValueText is not null) { QualityValueText.Text = _options.Quality.ToString(CultureInfo.InvariantCulture) + "%"; } } private void ApplyFilterCategory(string? categoryName) { if (Enum.TryParse(categoryName, true, out var cat)) { SelectedCategory = cat; } } private void ApplyQueueFilters() { var view = System.Windows.Data.CollectionViewSource.GetDefaultView(_activeQueue); if (view != null) { view.Filter = item => { if (item is QueueItem q) { return QueueFilterMatcher.Matches(q.FileName, _searchText, _selectedCategory); } return true; }; view.Refresh(); } } private void BatchSelectAll(object? parameter) { bool select = parameter is true; BatchQueueService.SetSelectionAll(_activeQueue, select); UpdateQueueSummary(); } private void BatchRemoveSelected() { BatchQueueService.RemoveSelected(_activeQueue); UpdateBadges(); UpdateProcessQueueButton(); UpdateActiveQueueVisibility(); } private void BatchClearCompleted() { BatchQueueService.ClearCompleted(_activeQueue); UpdateBadges(); UpdateProcessQueueButton(); UpdateActiveQueueVisibility(); } // ============== Export Log ============== private void OnExportLogClick(object sender, RoutedEventArgs e) { if (_pastResults.Count == 0) { MessageBox.Show(this, "저장할 이력이 없습니다.", "Everything2Everything", MessageBoxButton.OK, MessageBoxImage.Information); return; } var dlg = new Microsoft.Win32.SaveFileDialog { Title = "Export Log", FileName = $"Everything2Everything-log-{DateTime.Now:yyyyMMdd-HHmmss}.csv", DefaultExt = ".csv", Filter = "CSV (*.csv)|*.csv|JSON (*.json)|*.json", }; if (dlg.ShowDialog(this) != true) return; try { if (dlg.FileName.EndsWith(".json", StringComparison.OrdinalIgnoreCase)) ExportJson(dlg.FileName); else ExportCsv(dlg.FileName); MessageBox.Show(this, "저장되었습니다:\n" + dlg.FileName, "Everything2Everything", MessageBoxButton.OK, MessageBoxImage.Information); } catch (Exception ex) { MessageBox.Show(this, "저장 중 오류: " + ex.Message, "Everything2Everything", MessageBoxButton.OK, MessageBoxImage.Error); } } private void ExportCsv(string path) { var sb = new System.Text.StringBuilder(); sb.AppendLine("Date,Format,FileName,SourcePath,Size,Savings,Meta"); foreach (var group in _pastResults) foreach (var row in group.Entries) sb.AppendLine(string.Join(",", EscapeCsv(group.DateTitle), EscapeCsv(row.FormatLabel), EscapeCsv(row.FileName), EscapeCsv(row.SourcePath), EscapeCsv(row.SizeText), EscapeCsv(row.SavingsText), EscapeCsv(row.MetaLine))); File.WriteAllText(path, sb.ToString(), System.Text.Encoding.UTF8); } private void ExportJson(string path) { var data = _pastResults.Select(g => new { date = g.DateTitle, sessionSavings = HumanizeBytes(g.SessionSavingsBytes), entries = g.Entries.Select(r => new { format = r.FormatLabel, fileName = r.FileName, sourcePath = r.SourcePath, size = r.SizeText, savings = r.SavingsText, meta = r.MetaLine, }), }); File.WriteAllText(path, System.Text.Json.JsonSerializer.Serialize(data, new System.Text.Json.JsonSerializerOptions { WriteIndented = true }), System.Text.Encoding.UTF8); } private static string EscapeCsv(string? s) { s ??= ""; if (s.Contains('"') || s.Contains(',') || s.Contains('\n')) return "\"" + s.Replace("\"", "\"\"") + "\""; return s; } private async void OnProcessQueueClick(object sender, RoutedEventArgs e) { if (_activeQueue.Count == 0) return; var snapshot = _activeQueue.ToList(); foreach (var item in snapshot) item.SetPending(); _cts = new CancellationTokenSource(); UpdateProcessQueueButton(); ShowProcessingProgress(snapshot.Count); var engine = _engine; var options = BuildOptions(); var reporter = new Progress(p => { for (var i = 0; i < snapshot.Count; i++) { if (i < p.Index) snapshot[i].SetState("done"); else if (i == p.Index) snapshot[i].SetState($"{(int)(p.FileProgress * 100)}%"); else snapshot[i].SetState("queued"); } UpdateProcessingProgress(p); }); try { var sources = snapshot.Select(s => s.SourcePath).ToList(); var outputExt = SelectedOutputExtension ?? ".jpg"; var batchMode = (CombineToSingleCheck?.IsChecked == true) ? BatchMode.CombineToSingle : BatchMode.Independent; var results = await engine.ConvertManyAsync(sources, outputExt, options, reporter, batchMode, _cts.Token); foreach (var (item, result) in snapshot.Zip(results)) { long outputSize = 0; foreach (var p in result.OutputPaths) { try { outputSize += new FileInfo(p).Length; } catch { } } AddToHistory(new HistoryEntry( Timestamp: DateTime.Now, SourcePath: item.SourcePath, SourceFormat: item.FormatLabel, SourceSizeBytes: item.SourceSizeBytes, OutputSizeBytes: outputSize, OutputCount: result.OutputPaths.Count, MetaLine: item.MetaLine, Status: result.Status, Message: result.Message, OutputPaths: result.OutputPaths.ToList())); _activeQueue.Remove(item); } } catch (OperationCanceledException) { } catch (Exception ex) { MessageBox.Show(this, "변환 중 오류: " + ex.Message, "Everything2Everything", MessageBoxButton.OK, MessageBoxImage.Error); } finally { _cts = null; UpdateBadges(); UpdateProcessQueueButton(); UpdateActiveQueueVisibility(); ApplyAppDataStats(); HideProcessingProgress(); if (_activeQueue.Count == 0) ShowTab("Past"); } } private void ShowProcessingProgress(int totalCount) { if (ProcessingProgressPanel is null) return; ProcessingProgressPanel.Visibility = Visibility.Visible; ProcessingProgressBar.Value = 0; ProcessingPercentLabel.Text = "0%"; ProcessingCountLabel.Text = $"0 / {totalCount}"; ProcessingFileLabel.Text = "준비 중…"; } private void UpdateProcessingProgress(ConvertProgress p) { if (ProcessingProgressPanel is null) return; var total = Math.Max(1, p.Total); var overall = ((p.Index + p.FileProgress) / total) * 100.0; overall = Math.Clamp(overall, 0, 100); ProcessingProgressBar.Value = overall; ProcessingPercentLabel.Text = $"{overall:0.#}%"; ProcessingCountLabel.Text = $"{Math.Min(p.Index + 1, p.Total)} / {p.Total}"; ProcessingFileLabel.Text = string.IsNullOrEmpty(p.CurrentPath) ? "처리 중…" : Path.GetFileName(p.CurrentPath); } private void HideProcessingProgress() { if (ProcessingProgressPanel is null) return; ProcessingProgressPanel.Visibility = Visibility.Collapsed; if (CancelProcessingButton is not null) { CancelProcessingButton.IsEnabled = true; CancelProcessingButton.Content = "취소"; } } private void OnCancelProcessingClick(object sender, RoutedEventArgs e) { if (_cts is null) return; try { _cts.Cancel(); } catch { } if (CancelProcessingButton is not null) { CancelProcessingButton.IsEnabled = false; CancelProcessingButton.Content = "취소 중…"; } if (ProcessingFileLabel is not null) ProcessingFileLabel.Text = "취소 중…"; } // ============== History ============== private void AddToHistory(HistoryEntry entry) { AddToHistoryGroups(entry); HistoryStorage.Append(entry); } private void AddToHistoryGroups(HistoryEntry entry) { var label = FormatDateLabel(entry.Date); var group = _pastResults.FirstOrDefault(g => g.DateTitle == label); if (group is null) { group = new DateGroup(label); _pastResults.Insert(0, group); } group.Add(HistoryRow.From(entry)); } private void LoadHistory() { var entries = HistoryStorage.Load(); if (entries.Count == 0) { UpdatePastResultsVisibility(); return; } // 가장 오래된 것부터 추가 (Insert(0)이 누적) foreach (var e in entries.OrderBy(e => e.Timestamp)) AddToHistoryGroups(e); UpdatePastResultsVisibility(); } private Brush SafeBrush(string key) => (TryFindResource(key) as Brush) ?? (Application.Current?.TryFindResource(key) as Brush) ?? new SolidColorBrush(Color.FromRgb(0x10, 0xB9, 0x81)); private void SeedDemoHistory() { var today = FormatDateLabel(DateOnly.FromDateTime(DateTime.Today)); var todayGroup = new DateGroup(today); todayGroup.Add(new HistoryRow( FormatLabel: "PNG", FormatBrush: SafeBrush("FsFmtPng"), FileName: "hero_background_final_v2.png", MetaLine: "08:42:12 • 3200x1800", SizeText: "14.2 MB", SavingsText: "↓ 1.1 MB", SourcePath: "")); todayGroup.Add(new HistoryRow( FormatLabel: "HEIC", FormatBrush: SafeBrush("FsFmtHeic"), FileName: "portrait_session_04.heic", MetaLine: "08:35:45 • 4032x3024", SizeText: "6.8 MB", SavingsText: "↓ 2.4 MB", SourcePath: "")); todayGroup.SessionSavingsBytes = (long)(842.4 * 1024 * 1024); _pastResults.Add(todayGroup); var yesterday = FormatDateLabel(DateOnly.FromDateTime(DateTime.Today.AddDays(-1))); var yGroup = new DateGroup(yesterday); yGroup.Add(new HistoryRow( FormatLabel: "PDF", FormatBrush: SafeBrush("FsFmtPdf"), FileName: "Q3_Full_Marketing_Deck_v12.pdf", MetaLine: "17:22:10 • 124 Pages", SizeText: "245.4 MB", SavingsText: "↓ 12.8 MB", SourcePath: "")); todayGroup.Add(new HistoryRow( FormatLabel: "PNG", FormatBrush: SafeBrush("FsFmtPng"), FileName: "asset_bundle_archive_raw.png", MetaLine: "16:45:33 • 8000x8000", SizeText: "82.1 MB", SavingsText: "↓ 4.5 MB", SourcePath: "")); yGroup.SessionSavingsBytes = (long)(3.1 * 1024 * 1024 * 1024); _pastResults.Add(yGroup); UpdateBadges(); } private static string FormatDateLabel(DateOnly date) { var today = DateOnly.FromDateTime(DateTime.Today); if (date == today) return $"오늘 ({date:M월 d일})"; if (date == today.AddDays(-1)) return $"어제 ({date:M월 d일})"; return date.ToString("yyyy년 M월 d일 (ddd)", CultureInfo.GetCultureInfo("ko-KR")); } private void ApplyAppDataStats() { var todayLabel = FormatDateLabel(DateOnly.FromDateTime(DateTime.Today)); var todayGroup = _pastResults.FirstOrDefault(g => g.DateTitle == todayLabel); var processedToday = todayGroup?.Entries.Count ?? 0; var allSavings = _pastResults.Sum(g => g.SessionSavingsBytes); ProcessedTodayText.Text = processedToday.ToString("N0", CultureInfo.InvariantCulture); SpaceSavedText.Text = HumanizeBytes(allSavings); } // ============== Top-bar actions ============== private void OnRegisterClick(object sender, RoutedEventArgs e) { try { ContextMenuRegistrar.Register(_engine); MessageBox.Show(this, "컨텍스트 메뉴를 등록했습니다.\n파일 우클릭 → \"추가 옵션 표시\" 또는 \"JPEG로 빠른 변환/변환…\".", "Everything2Everything", MessageBoxButton.OK, MessageBoxImage.Information); } catch (Exception ex) { MessageBox.Show(this, "등록 중 오류: " + ex.Message, "Everything2Everything", MessageBoxButton.OK, MessageBoxImage.Error); } } private void OnDiagnoseClick(object sender, RoutedEventArgs e) { var window = new DiagnoseWindow(_engine) { Owner = this }; window.ShowDialog(); } private void OnClearAllClick(object sender, RoutedEventArgs e) { if (TabActiveBtn.IsChecked == true) { _activeQueue.Clear(); UpdateBadges(); UpdateProcessQueueButton(); UpdateActiveQueueVisibility(); RefreshAvailableOutputFormats(); } else if (TabPastBtn.IsChecked == true) { var confirm = MessageBox.Show(this, "변환 기록 전체를 삭제하시겠습니까?\n영구 저장된 이력도 함께 삭제됩니다.", "Everything2Everything", MessageBoxButton.OKCancel, MessageBoxImage.Question); if (confirm != MessageBoxResult.OK) return; _pastResults.Clear(); HistoryStorage.Clear(); } UpdateBadges(); UpdateProcessQueueButton(); UpdateActiveQueueVisibility(); ApplyAppDataStats(); } private static void OpenFolderForPath(string? path) { if (string.IsNullOrEmpty(path) || !File.Exists(path)) return; try { System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo { FileName = "explorer.exe", Arguments = $"/select,\"{path}\"", UseShellExecute = true, }); } catch { } } public static string HumanizeBytes(long bytes) { if (bytes <= 0) return "0 B"; string[] units = { "B", "KB", "MB", "GB", "TB" }; double size = bytes; var unit = 0; while (size >= 1024 && unit < units.Length - 1) { size /= 1024; unit++; } return $"{size:0.#} {units[unit]}"; } // ======================================================================== // Output format selection (피보팅: 양방향 매트릭스) // ======================================================================== private static readonly OutputFormatInfo[] AllFormats = { new(".jpg", "JPEG", "JPG", "FsFmtJpg"), new(".png", "PNG", "PNG", "FsFmtPng"), new(".webp", "WebP", "WEBP", "FsFmtWebp"), new(".avif", "AVIF", "AVIF", "FsFmtAvif"), new(".bmp", "BMP", "BMP", "FsFmtBmp"), new(".tif", "TIFF", "TIF", "FsFmtTiff"), new(".gif", "GIF", "GIF", "FsFmtGif"), new(".pdf", "PDF", "PDF", "FsFmtPdf"), new(".docx", "Word", "DOCX", "FsFmtDocx"), new(".html", "HTML", "HTML", "FsFmtHtml"), new(".md", "Markdown", "MD", "FsFmtOther"), new(".txt", "텍스트", "TXT", "FsFmtOther"), new(".csv", "CSV", "CSV", "FsFmtCsv"), new(".json", "JSON", "JSON", "FsFmtJson"), new(".xlsx", "Excel", "XLSX", "FsFmtXlsx"), new(".svg", "SVG", "SVG", "FsFmtSvg"), new(".mp4", "MP4", "MP4", "FsFmtVideo"), new(".webm", "WebM", "WEBM", "FsFmtVideo"), new(".mkv", "MKV", "MKV", "FsFmtVideo"), new(".mov", "MOV", "MOV", "FsFmtVideo"), new(".avi", "AVI", "AVI", "FsFmtVideo"), new(".mp3", "MP3", "MP3", "FsFmtAudio"), new(".aac", "AAC", "AAC", "FsFmtAudio"), new(".m4a", "M4A", "M4A", "FsFmtAudio"), new(".opus", "Opus", "OPUS", "FsFmtAudio"), new(".ogg", "OGG", "OGG", "FsFmtAudio"), new(".flac", "FLAC", "FLAC", "FsFmtAudio"), new(".wav", "WAV", "WAV", "FsFmtAudio"), }; private bool _suppressFormatChanged; private void InitializeOutputFormats() { RefreshAvailableOutputFormats(); } // 드롭다운 항목: 형식 색 dot + 라벨 + 손실 등급 dot (변환 전에 색·손실을 한눈에) private object BuildFormatItemContent(OutputFormatInfo f) { var panel = new StackPanel { Orientation = Orientation.Horizontal, VerticalAlignment = VerticalAlignment.Center }; panel.Children.Add(new System.Windows.Shapes.Ellipse { Width = 8, Height = 8, VerticalAlignment = VerticalAlignment.Center, Margin = new Thickness(0, 0, 9, 0), Fill = ResBrush(f.ColorResource), }); panel.Children.Add(new TextBlock { Text = $"{f.DisplayName} ({f.Extension})", VerticalAlignment = VerticalAlignment.Center, }); var loss = WorstLossForQueue(f.Extension); if (loss is LossClass lc) { panel.Children.Add(new System.Windows.Shapes.Ellipse { Width = 6, Height = 6, VerticalAlignment = VerticalAlignment.Center, Margin = new Thickness(8, 1, 0, 0), Fill = ResBrush(LossDotKey(lc)), ToolTip = LossLabel(lc), }); } return panel; } private LossClass? WorstLossForQueue(string outputExt) { if (_activeQueue.Count == 0) return null; var graph = _engine.Providers.Graph; LossClass? worst = null; foreach (var item in _activeQueue) { var inExt = Path.GetExtension(item.SourcePath); var path = graph.FindBestPath(inExt, outputExt, 3); if (path is null || path.Count == 0) continue; var pw = path.Max(e => e.Loss); if (worst is null || pw > worst.Value) worst = pw; } return worst; } private static Brush ResBrush(string key) => (Brush)Application.Current.Resources[key]; private static string LossDotKey(LossClass lc) => lc switch { LossClass.Lossless => "FsLossLosslessDot", LossClass.Container => "FsLossContainerDot", LossClass.Recode => "FsLossRecodeDot", LossClass.Rasterize => "FsLossRasterizeDot", _ => "FsLossContainerDot", }; private static string LossLabel(LossClass lc) => lc switch { LossClass.Lossless => "무손실 — 픽셀·내용 보존", LossClass.Container => "구조 변경 — 내용 보존", LossClass.Recode => "재인코딩 — 약간의 품질 손실", LossClass.Rasterize => "래스터화 — 편집성 상실 (단방향)", _ => "", }; private void RefreshAvailableOutputFormats() { if (OutputFormatCombo is null) return; var available = _engine.Providers.AvailableOutputsForFiles( _activeQueue.Select(q => q.SourcePath).ToList()); var visible = AllFormats.Where(f => available.Contains(f.Extension)).ToList(); var hasCommonFormats = visible.Count > 0; string? keepExt = null; if (hasCommonFormats) { keepExt = SelectedOutputExtension; if (keepExt is null || !visible.Any(v => string.Equals(v.Extension, keepExt, StringComparison.OrdinalIgnoreCase))) keepExt = visible[0].Extension; } _suppressFormatChanged = true; try { OutputFormatCombo.Items.Clear(); if (hasCommonFormats) { foreach (var f in visible) { OutputFormatCombo.Items.Add(new ComboBoxItem { Content = BuildFormatItemContent(f), Tag = f.Extension, }); } for (var i = 0; i < OutputFormatCombo.Items.Count; i++) { if (((ComboBoxItem)OutputFormatCombo.Items[i]!).Tag is string tag && string.Equals(tag, keepExt, StringComparison.OrdinalIgnoreCase)) { OutputFormatCombo.SelectedIndex = i; break; } } } else if (_activeQueue.Count > 0) { OutputFormatCombo.Items.Add(new ComboBoxItem { Content = "(공통 변환 형식 없음)", Tag = string.Empty, IsEnabled = false, }); OutputFormatCombo.SelectedIndex = 0; } } finally { _suppressFormatChanged = false; } SelectedOutputExtension = keepExt; UpdateOutputFormatBadge(keepExt); UpdateQualityPanelForFormat(keepExt); UpdateOutputDestHint(keepExt); UpdateCombineState(keepExt); UpdateProcessQueueButton(); UpdateSmartPresetsForFormat(keepExt); UpdateConversionPipelineCard(PreviewFormatText?.Text, PreviewSizeText?.Text); if (OutputFormatHint is not null) { OutputFormatHint.Text = _activeQueue.Count == 0 ? "큐에 파일을 추가하면 변환 가능한 형식으로 자동 필터링됩니다" : hasCommonFormats ? $"큐의 모든 파일이 변환 가능한 형식 ({visible.Count}개)" : "선택된 파일들의 공통 변환 형식이 없습니다 (서로 다른 미디어)"; } } private void UpdateCombineState(string? extension) { if (CombineToSingleCheck is null || CombineHint is null) return; var ext = extension ?? string.Empty; var combinableOutput = ConversionEngine.CanCombine(ext); var allInputsCombinable = _activeQueue.Count > 0 && _activeQueue.All(q => ConversionEngine.CanCombineInput(q.SourcePath)); var enabled = combinableOutput && allInputsCombinable && _activeQueue.Count >= 2; CombineToSingleCheck.IsEnabled = enabled; if (!enabled && CombineToSingleCheck.IsChecked == true) CombineToSingleCheck.IsChecked = false; CombineHint.Text = (combinableOutput, _activeQueue.Count) switch { (false, _) => "PDF/TIFF/GIF 출력일 때만 단일 파일 결합이 가능합니다", (true, 0) => "PDF/TIFF/GIF 출력에 한해 큐의 이미지들을 한 파일로 결합합니다", (true, 1) => "결합하려면 큐에 2개 이상의 이미지가 필요합니다", (true, _) when !allInputsCombinable => "결합은 이미지 입력만 지원합니다 (PDF/DOCX 등 제외)", _ => $"체크 시 큐의 {_activeQueue.Count}개 이미지를 단일 {ext.TrimStart('.').ToUpperInvariant()}로 결합", }; } private void OnOutputFormatSelected() { if (_suppressFormatChanged) return; if (OutputFormatCombo.SelectedItem is ComboBoxItem item && item.Tag is string ext) { SelectedOutputExtension = ext; UpdateOutputFormatBadge(ext); UpdateQualityPanelForFormat(ext); UpdateOutputDestHint(ext); UpdateSmartPresetsForFormat(ext); UpdateConversionPipelineCard(PreviewFormatText?.Text, PreviewSizeText?.Text); } } private void UpdateOutputFormatBadge(string? extension) { if (OutputFormatBadge is null || OutputFormatBadgeText is null) return; var info = AllFormats.FirstOrDefault(f => string.Equals(f.Extension, extension, StringComparison.OrdinalIgnoreCase)); if (info is null) { OutputFormatBadgeText.Text = "—"; OutputFormatBadge.Background = System.Windows.Media.Brushes.Gray; return; } OutputFormatBadgeText.Text = info.BadgeText; var resource = TryFindResource(info.ColorResource); if (resource is System.Windows.Media.Brush brush) OutputFormatBadge.Background = brush; } private void UpdateQualityPanelForFormat(string? extension) { var ext = extension?.ToLowerInvariant(); var isImageQuality = ext is ".jpg" or ".jpeg" or ".webp" or ".avif"; var isVideo = ext is ".mp4" or ".mkv" or ".webm" or ".mov" or ".avi"; var isAudio = ext is ".mp3" or ".aac" or ".m4a" or ".opus" or ".ogg" or ".flac" or ".wav"; var isPdf = ext is ".pdf"; if (QualityPanel is not null) { QualityPanel.Visibility = isImageQuality ? Visibility.Visible : Visibility.Collapsed; if (QualityLabelText is not null) { QualityLabelText.Text = ext switch { ".jpg" or ".jpeg" => "JPEG 압축 품질", ".webp" => "WebP 압축 품질", ".avif" => "AVIF 압축 품질", _ => "압축 품질", }; } } if (VideoQuickPanel is not null) VideoQuickPanel.Visibility = isVideo ? Visibility.Visible : Visibility.Collapsed; if (AudioQuickPanel is not null) AudioQuickPanel.Visibility = isAudio ? Visibility.Visible : Visibility.Collapsed; if (PdfQuickPanel is not null) PdfQuickPanel.Visibility = isPdf ? Visibility.Visible : Visibility.Collapsed; UpdateMediaPanelForFormat(extension); } /// 출력이 영상/오디오/PDF/이미지일 때 상세 인코딩 폴드아웃 서브패널 노출(Fluent 2 점진적 공개 패턴). private void UpdateMediaPanelForFormat(string? extension) { if (AdvancedOptionsExpander is null) return; var ext = (extension ?? string.Empty).ToLowerInvariant(); var isVideo = ext is ".mp4" or ".mkv" or ".webm" or ".mov" or ".avi"; var isAudio = ext is ".mp3" or ".aac" or ".m4a" or ".opus" or ".ogg" or ".flac" or ".wav"; var isPdf = ext is ".pdf"; var isImage = ext is ".jpg" or ".jpeg" or ".png" or ".webp" or ".avif" or ".tif" or ".tiff" or ".bmp" or ".gif"; if (AdvancedVideoPanel is not null) AdvancedVideoPanel.Visibility = isVideo ? Visibility.Visible : Visibility.Collapsed; if (AdvancedAudioPanel is not null) AdvancedAudioPanel.Visibility = (isVideo || isAudio) ? Visibility.Visible : Visibility.Collapsed; if (AdvancedPdfPanel is not null) AdvancedPdfPanel.Visibility = isPdf ? Visibility.Visible : Visibility.Collapsed; if (AdvancedImagePanel is not null) AdvancedImagePanel.Visibility = isImage ? Visibility.Visible : Visibility.Collapsed; } private void UpdateOutputDestHint(string? extension) { if (OutputDestHint is null) return; var folder = (extension ?? ".jpg").TrimStart('.').ToLowerInvariant(); OutputDestHint.Text = $"비워두면 원본 옆 _{folder} 폴더에 저장됩니다"; } private sealed record OutputFormatInfo(string Extension, string DisplayName, string BadgeText, string ColorResource); } // ============================================================ // View models // ============================================================ public sealed class QueueItem : INotifyPropertyChanged { private string _state = "queued"; private double _progressValue; private Visibility _progressVisibility = Visibility.Collapsed; private bool _isSelected; private string? _selectedOutputExtension; public string SourcePath { get; init; } = ""; public string FileName { get; init; } = ""; public string FormatLabel { get; init; } = ""; public Brush FormatBrush { get; init; } = Brushes.Gray; public string SizeText { get; init; } = ""; public string MetaLine { get; init; } = ""; public long SourceSizeBytes { get; init; } public bool IsSelected { get => _isSelected; set { _isSelected = value; Raise(nameof(IsSelected)); } } public string? SelectedOutputExtension { get => _selectedOutputExtension; set { _selectedOutputExtension = value; Raise(nameof(SelectedOutputExtension)); } } public bool IsDone => _state == "done"; /// 형식 카테고리 글리프(라벨 아이콘). public System.Windows.Media.ImageSource GlyphSource => CategoryGlyphs.ForExtension(Path.GetExtension(SourcePath)); public string StateText { get => _state; set { _state = value; Raise(nameof(StateText)); Raise(nameof(DisplayStateText)); Raise(nameof(IsDone)); } } /// 사용자에게 표시되는 정제된 한국어 상태 텍스트 (AGENTS.md Rule 3). public string DisplayStateText => _state switch { "queued" => "대기 중", "done" => "변환 완료", _ => _state, }; public Brush StateBrush => _state switch { "queued" => (Application.Current?.TryFindResource("FsTextTertiary") as Brush) ?? Brushes.Gray, "done" => (Application.Current?.TryFindResource("FsAccentGreen") as Brush) ?? Brushes.LightGreen, _ => (Application.Current?.TryFindResource("FsAccentBlue") as Brush) ?? Brushes.DodgerBlue, }; public double ProgressValue { get => _progressValue; private set { _progressValue = value; Raise(nameof(ProgressValue)); } } public Visibility ProgressVisibility { get => _progressVisibility; private set { _progressVisibility = value; Raise(nameof(ProgressVisibility)); } } public void SetPending() { StateText = "queued"; ProgressValue = 0; ProgressVisibility = Visibility.Collapsed; Raise(nameof(StateBrush)); } public void SetState(string s) { StateText = s; Raise(nameof(StateBrush)); if (s == "queued") { ProgressValue = 0; ProgressVisibility = Visibility.Collapsed; } else if (s == "done") { ProgressValue = 100; ProgressVisibility = Visibility.Visible; } else if (s.EndsWith('%') && double.TryParse(s.TrimEnd('%'), out var pct)) { ProgressValue = pct; ProgressVisibility = Visibility.Visible; } } public static QueueItem FromPath(string path) { var ext = Path.GetExtension(path).TrimStart('.').ToLowerInvariant(); var (label, brushKey) = FormatPalette.For(ext); long size = 0; try { size = new FileInfo(path).Length; } catch { } var brush = (Application.Current?.TryFindResource(brushKey) as Brush) ?? new SolidColorBrush(Color.FromRgb(0x10, 0xB9, 0x81)); return new QueueItem { SourcePath = path, FileName = Path.GetFileName(path), FormatLabel = label, FormatBrush = brush, SizeText = MainWindow.HumanizeBytes(size), MetaLine = $"{ext.ToUpperInvariant()} • {MainWindow.HumanizeBytes(size)}", SourceSizeBytes = size, }; } public event PropertyChangedEventHandler? PropertyChanged; private void Raise([CallerMemberName] string? n = null) => PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(n)); } public sealed class DateGroup : INotifyPropertyChanged { public string DateTitle { get; } public ObservableCollection Entries { get; } = new(); public long SessionSavingsBytes { get; set; } public string SessionSavingsText => $"세션 절감: {MainWindow.HumanizeBytes(SessionSavingsBytes)}"; public DateGroup(string dateTitle) { DateTitle = dateTitle; } public void Add(HistoryRow row) { Entries.Insert(0, row); SessionSavingsBytes += row.SavingsBytes; Raise(nameof(SessionSavingsText)); } public event PropertyChangedEventHandler? PropertyChanged; private void Raise([CallerMemberName] string? n = null) => PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(n)); } public sealed record HistoryRow( string FormatLabel, Brush FormatBrush, string FileName, string MetaLine, string SizeText, string SavingsText, string SourcePath, string? OutputPath = null, long SavingsBytes = 0) { public string RevealPath => OutputPath ?? SourcePath; /// 형식 카테고리 글리프(라벨 아이콘). public System.Windows.Media.ImageSource GlyphSource => CategoryGlyphs.ForExtension(Path.GetExtension(SourcePath)); public static HistoryRow From(HistoryEntry e) { var ext = Path.GetExtension(e.SourcePath).TrimStart('.').ToLowerInvariant(); var (label, brushKey) = FormatPalette.For(ext); var saved = e.SavingsBytes; var arrow = saved >= 0 ? "↓" : "↑"; var brush = (Application.Current?.TryFindResource(brushKey) as Brush) ?? new SolidColorBrush(Color.FromRgb(0x10, 0xB9, 0x81)); return new HistoryRow( FormatLabel: label, FormatBrush: brush, FileName: Path.GetFileName(e.SourcePath), MetaLine: $"{e.Timestamp:HH:mm:ss} • {e.OutputCount}개 파일", SizeText: MainWindow.HumanizeBytes(e.SourceSizeBytes), SavingsText: $"{arrow} {MainWindow.HumanizeBytes(Math.Abs(saved))}", SourcePath: e.SourcePath, OutputPath: e.PrimaryOutputPath, SavingsBytes: saved); } } internal static class FormatPalette { public static (string Label, string BrushKey) For(string ext) => ext switch { "pdf" => ("PDF", "FsFmtPdf"), "png" => ("PNG", "FsFmtPng"), "heic" or "heif" => ("HEIC", "FsFmtHeic"), "jpg" or "jpeg" or "jpe" => ("JPG", "FsFmtJpg"), "doc" or "docx" => ("DOCX", "FsFmtDocx"), "html" or "htm" => ("HTML", "FsFmtHtml"), "hwp" or "hwpx" => ("HWP", "FsFmtHwp"), "gif" => ("GIF", "FsFmtGif"), "tif" or "tiff" => ("TIFF", "FsFmtTiff"), "webp" => ("WEBP", "FsFmtWebp"), "bmp" => ("BMP", "FsFmtBmp"), "raw" or "dng" or "nef" or "cr2" or "cr3" or "arw" or "raf" or "orf" or "rw2" or "srw" or "pef" => ("RAW", "FsFmtRaw"), // 신규 카테고리 (output AllFormats 매핑과 동일 hue 유지) "csv" => ("CSV", "FsFmtCsv"), "json" => ("JSON", "FsFmtJson"), "xlsx" or "xls" => ("XLSX", "FsFmtXlsx"), "svg" => ("SVG", "FsFmtSvg"), "mp4" or "webm" or "mkv" or "mov" or "avi" or "m4v" => (ext.ToUpperInvariant(), "FsFmtVideo"), "mp3" or "aac" or "m4a" or "opus" or "ogg" or "oga" or "flac" or "wav" => (ext.ToUpperInvariant(), "FsFmtAudio"), _ => (ext.ToUpperInvariant(), "FsFmtOther"), }; } internal sealed class RelayCommand : ICommand { private readonly Action _execute; private readonly Func? _canExecute; public RelayCommand(Action execute, Func? canExecute = null) { _execute = execute; _canExecute = canExecute; } public bool CanExecute(object? parameter) => _canExecute?.Invoke(parameter) ?? true; public void Execute(object? parameter) => _execute(parameter); public event EventHandler? CanExecuteChanged { add { CommandManager.RequerySuggested += value; } remove { CommandManager.RequerySuggested -= value; } } }