From 28624c386ea87d796e58a5d571fe666f91b9d8bb Mon Sep 17 00:00:00 2001 From: Yun Chan Date: Thu, 3 Sep 2026 18:47:45 +0900 Subject: [PATCH] feat(negotiation): comprehensive media conversion filter negotiation across graph, UI, and shell - Prevent cross-category graph leakage (png -> txt -> docx) in ConversionGraph.FindBestPath and ReachableOutputs - Distinguish Video and Audio in MediaConversionNegotiator (block mp3->gif, allow mp4->png frame extraction) - Handle empty common output intersection in MainWindow without forcing .jpg fallback - Expand PopularOutputs in ContextMenuRegistrar to support mp4, mp3, and xlsx - Add comprehensive TDD unit tests (213 passing tests) --- .../Shell/ContextMenuRegistrar.cs | 25 ++- .../Views/MainWindow.xaml.cs | 68 +++++--- .../ConversionEngine.cs | 6 + .../Filters/MediaConversionNegotiator.cs | 120 ++++++++++++++ .../Providers/ConversionGraph.cs | 7 +- .../ContextMenuRegistrarTests.cs | 59 +++++++ .../MediaNegotiationTests.cs | 154 ++++++++++++++++++ 7 files changed, 410 insertions(+), 29 deletions(-) create mode 100644 src/Everything2Everything.Core/Filters/MediaConversionNegotiator.cs create mode 100644 src/Everything2Everything.Tests/ContextMenuRegistrarTests.cs create mode 100644 src/Everything2Everything.Tests/MediaNegotiationTests.cs diff --git a/src/Everything2Everything.App/Shell/ContextMenuRegistrar.cs b/src/Everything2Everything.App/Shell/ContextMenuRegistrar.cs index a140229..0b0820d 100644 --- a/src/Everything2Everything.App/Shell/ContextMenuRegistrar.cs +++ b/src/Everything2Everything.App/Shell/ContextMenuRegistrar.cs @@ -4,7 +4,7 @@ using Microsoft.Win32; namespace Everything2Everything.App.Shell; -internal static class ContextMenuRegistrar +public static class ContextMenuRegistrar { private const string MainVerb = "Everything2Everything"; private const string MainLabel = "Everything2Everything으로 변환"; @@ -25,8 +25,22 @@ internal static class ContextMenuRegistrar (".gif", "GIF (.gif)", "10"), (".tif", "TIFF (.tif)", "11"), (".bmp", "BMP (.bmp)", "12"), + (".mp4", "MP4 (.mp4)", "13"), + (".mp3", "MP3 (.mp3)", "14"), + (".xlsx", "Excel (.xlsx)", "15"), }; + public static IReadOnlyList<(string Ext, string Label, string SortPrefix)> GetAvailableOutputs(ConversionEngine engine, string ext) + { + var outputs = engine.Providers.OutputsForInput(ext) + .Select(o => o.ToLowerInvariant()) + .ToHashSet(); + + return PopularOutputs + .Where(p => outputs.Contains(p.Ext) && !string.Equals(p.Ext, ext, StringComparison.OrdinalIgnoreCase)) + .ToList(); + } + public static void Register(ConversionEngine engine) { var exe = GetAppExecutablePath(); @@ -34,14 +48,7 @@ internal static class ContextMenuRegistrar foreach (var ext in CollectInputExtensions(engine)) { - var outputs = engine.Providers.OutputsForInput(ext) - .Select(o => o.ToLowerInvariant()) - .ToHashSet(); - - var availableOutputs = PopularOutputs - .Where(p => outputs.Contains(p.Ext) && !string.Equals(p.Ext, ext, StringComparison.OrdinalIgnoreCase)) - .ToList(); - + var availableOutputs = GetAvailableOutputs(engine, ext); if (availableOutputs.Count == 0) continue; WriteCascade(ext, exe, icon, availableOutputs); diff --git a/src/Everything2Everything.App/Views/MainWindow.xaml.cs b/src/Everything2Everything.App/Views/MainWindow.xaml.cs index cb6a76f..57b3635 100644 --- a/src/Everything2Everything.App/Views/MainWindow.xaml.cs +++ b/src/Everything2Everything.App/Views/MainWindow.xaml.cs @@ -325,6 +325,11 @@ public partial class MainWindow : Wpf.Ui.Controls.FluentWindow ProcessQueueButton.Content = "Idle — drop files to begin"; ProcessQueueButton.IsEnabled = false; } + else if (string.IsNullOrEmpty(SelectedOutputExtension)) + { + ProcessQueueButton.Content = "변환 불가 (공통 형식 없음)"; + ProcessQueueButton.IsEnabled = false; + } else { ProcessQueueButton.Content = $"Process Queue ({count})"; @@ -1128,33 +1133,49 @@ public partial class MainWindow : Wpf.Ui.Controls.FluentWindow _activeQueue.Select(q => q.SourcePath).ToList()); var visible = AllFormats.Where(f => available.Contains(f.Extension)).ToList(); - if (visible.Count == 0) - visible = AllFormats.Where(f => f.Extension == ".jpg").ToList(); + var hasCommonFormats = visible.Count > 0; - var keepExt = SelectedOutputExtension; - if (keepExt is null || !visible.Any(v => string.Equals(v.Extension, keepExt, StringComparison.OrdinalIgnoreCase))) - keepExt = visible[0].Extension; + 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(); - foreach (var f in visible) + 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 = BuildFormatItemContent(f), - Tag = f.Extension, + Content = "(공통 변환 형식 없음)", + Tag = string.Empty, + IsEnabled = false, }); - } - 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; - } + OutputFormatCombo.SelectedIndex = 0; } } finally @@ -1167,12 +1188,15 @@ public partial class MainWindow : Wpf.Ui.Controls.FluentWindow UpdateQualityPanelForFormat(keepExt); UpdateOutputDestHint(keepExt); UpdateCombineState(keepExt); + UpdateProcessQueueButton(); if (OutputFormatHint is not null) { OutputFormatHint.Text = _activeQueue.Count == 0 ? "큐에 파일을 추가하면 변환 가능한 형식으로 자동 필터링됩니다" - : $"큐의 모든 파일이 변환 가능한 형식 ({visible.Count}개)"; + : hasCommonFormats + ? $"큐의 모든 파일이 변환 가능한 형식 ({visible.Count}개)" + : "선택된 파일들의 공통 변환 형식이 없습니다 (서로 다른 미디어)"; } } @@ -1215,9 +1239,15 @@ public partial class MainWindow : Wpf.Ui.Controls.FluentWindow 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) return; + if (info is null) + { + OutputFormatBadgeText.Text = "—"; + OutputFormatBadge.Background = System.Windows.Media.Brushes.Gray; + return; + } OutputFormatBadgeText.Text = info.BadgeText; var resource = TryFindResource(info.ColorResource); diff --git a/src/Everything2Everything.Core/ConversionEngine.cs b/src/Everything2Everything.Core/ConversionEngine.cs index 43508a4..28ec190 100644 --- a/src/Everything2Everything.Core/ConversionEngine.cs +++ b/src/Everything2Everything.Core/ConversionEngine.cs @@ -1,3 +1,4 @@ +using Everything2Everything.Core.Filters; using Everything2Everything.Core.Providers; namespace Everything2Everything.Core; @@ -106,6 +107,11 @@ public sealed class ConversionEngine var output = ConversionPair.Normalize(outputExtension); var inputExt = ConversionPair.Normalize(Path.GetExtension(sourcePath)); + if (!MediaConversionNegotiator.CanConvert(inputExt, output)) + { + return ConvertResult.Fail(sourcePath, $"{inputExt} → {output} 변환은 지원하지 않는 미디어 전환입니다."); + } + // 그래프 경로 탐색: 직접 엣지가 있으면 1홉, 없으면 손실 가중치 기반 멀티홉을 자동 합성. var maxHops = options.AllowMultiHop ? Math.Max(1, options.MaxHops) : 1; var path = _registry.Graph.FindBestPath(inputExt, output, maxHops, !options.AvoidLossy); diff --git a/src/Everything2Everything.Core/Filters/MediaConversionNegotiator.cs b/src/Everything2Everything.Core/Filters/MediaConversionNegotiator.cs new file mode 100644 index 0000000..b1c1324 --- /dev/null +++ b/src/Everything2Everything.Core/Filters/MediaConversionNegotiator.cs @@ -0,0 +1,120 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using Everything2Everything.Core.Providers; + +namespace Everything2Everything.Core.Filters; + +/// +/// 미디어 카테고리(Image, Document, Media, Data) 간의 변환 호환성 및 출력 필터링을 협상(Negotiation)하는 도메인 서비스. +/// 이미지→Word(DOCX) 등 비정상적인 미디어 전이 노출 및 실행을 방지한다. +/// +public static class MediaConversionNegotiator +{ + private static readonly HashSet AllowedImageToDocumentExtensions = new(StringComparer.OrdinalIgnoreCase) + { + ".pdf", // 이미지 문서 캡슐화 (PDF) + ".txt", // OCR 텍스트 추출 (TXT) + }; + + private static readonly HashSet VideoExtensions = new(StringComparer.OrdinalIgnoreCase) + { + ".mp4", ".mkv", ".webm", ".mov", ".avi", ".m4v" + }; + + private static readonly HashSet AudioExtensions = new(StringComparer.OrdinalIgnoreCase) + { + ".mp3", ".wav", ".flac", ".aac", ".m4a", ".ogg", ".opus" + }; + + private static readonly HashSet AllowedVideoToImageExtensions = new(StringComparer.OrdinalIgnoreCase) + { + ".gif", // 영상 → GIF 애니메이션 + ".png", // 영상 → 대표 프레임 스틸컷 + ".jpg", + ".jpeg", + ".webp", + ".bmp" + }; + + public static bool IsVideo(string ext) => VideoExtensions.Contains(ConversionPair.Normalize(ext)); + public static bool IsAudio(string ext) => AudioExtensions.Contains(ConversionPair.Normalize(ext)); + + /// + /// 입력 확장자와 출력 확장자 간의 미디어 변환 가능 여부를 판정한다. + /// + public static bool CanConvert(string inputExt, string outputExt) + { + var input = ConversionPair.Normalize(inputExt); + var output = ConversionPair.Normalize(outputExt); + + if (string.Equals(input, output, StringComparison.OrdinalIgnoreCase)) + return true; + + var inCat = QueueFilterMatcher.GetCategory(input); + var outCat = QueueFilterMatcher.GetCategory(output); + + // 1. 미디어(영상/음성) 입력 세분화 + if (inCat == FilterCategory.Media) + { + var isVideoIn = IsVideo(input); + var isAudioIn = IsAudio(input); + + if (outCat == FilterCategory.Media) + { + var isVideoOut = IsVideo(output); + var isAudioOut = IsAudio(output); + + if (isVideoIn) + { + // 비디오는 비디오 간 변환 또는 오디오 추출 허용 + return isVideoOut || isAudioOut; + } + if (isAudioIn) + { + // 오디오는 오디오 간 변환만 허용 (오디오→비디오 변환 불가) + return isAudioOut; + } + return true; + } + + if (outCat == FilterCategory.Image) + { + // 비디오만 GIF 애니메이션 및 대표 프레임 추출 허용. 오디오는 이미지 불가! + return isVideoIn && AllowedVideoToImageExtensions.Contains(output); + } + + // 미디어 → 문서/데이터 변환 불가 + return false; + } + + return (inCat, outCat) switch + { + // 2. 이미지 입력 + (FilterCategory.Image, FilterCategory.Image) => true, + (FilterCategory.Image, FilterCategory.Document) => AllowedImageToDocumentExtensions.Contains(output), + (FilterCategory.Image, _) => false, + + // 3. 데이터(CSV/JSON/XLSX) 입력: 표 데이터 상호 변환만 허용 + (FilterCategory.Data, FilterCategory.Data) => true, + (FilterCategory.Data, _) => false, + + // 4. 문서(PDF/DOCX/HWP/HTML/MD/TXT) 입력 + (FilterCategory.Document, FilterCategory.Document) => true, + (FilterCategory.Document, FilterCategory.Image) => true, // 문서 페이지 렌더링 + (FilterCategory.Document, _) => false, + + // 기본: 동일 카테고리 허용 + _ => inCat == outCat, + }; + } + + /// + /// 후보 출력 확장자 목록 중 입력 미디어와 호환되는 유효한 출력 확장자만 필터링하여 반환한다. + /// + public static IEnumerable FilterAvailableOutputs(string inputExt, IEnumerable candidateOutputs) + { + return candidateOutputs.Where(outExt => CanConvert(inputExt, outExt)); + } +} diff --git a/src/Everything2Everything.Core/Providers/ConversionGraph.cs b/src/Everything2Everything.Core/Providers/ConversionGraph.cs index b161e44..3102cc3 100644 --- a/src/Everything2Everything.Core/Providers/ConversionGraph.cs +++ b/src/Everything2Everything.Core/Providers/ConversionGraph.cs @@ -1,3 +1,5 @@ +using Everything2Everything.Core.Filters; + namespace Everything2Everything.Core.Providers; /// @@ -55,7 +57,7 @@ public sealed class ConversionGraph foreach (var e in EdgesFrom(node)) { if (!allowLossy && e.Loss == LossClass.Rasterize) continue; - if (!string.Equals(e.To, start, StringComparison.OrdinalIgnoreCase)) + if (!string.Equals(e.To, start, StringComparison.OrdinalIgnoreCase) && MediaConversionNegotiator.CanConvert(start, e.To)) result.Add(e.To); var next = (e.To, hop + 1); if (visited.Add(next)) @@ -75,6 +77,9 @@ public sealed class ConversionGraph var goal = ConversionPair.Normalize(outputExt); if (maxHops < 1) maxHops = 1; + if (!MediaConversionNegotiator.CanConvert(start, goal)) + return null; + if (string.Equals(start, goal, StringComparison.OrdinalIgnoreCase)) { Edge? best = null; diff --git a/src/Everything2Everything.Tests/ContextMenuRegistrarTests.cs b/src/Everything2Everything.Tests/ContextMenuRegistrarTests.cs new file mode 100644 index 0000000..136a53a --- /dev/null +++ b/src/Everything2Everything.Tests/ContextMenuRegistrarTests.cs @@ -0,0 +1,59 @@ +using System.Linq; +using Everything2Everything.App.Shell; +using Everything2Everything.Core; +using Xunit; + +namespace Everything2Everything.Tests; + +/// +/// 윈도우 탐색기 우클릭 컨텍스트 메뉴 네고시에이션 테스트 (TDD). +/// 동영상, 음성 추출, 엑셀 표 데이터 등 킬러 포맷의 정상 노출 및 비정상 변환(png→docx 등) 차단을 검증한다. +/// +public class ContextMenuRegistrarTests +{ + private static ConversionEngine CreateEngine() => Everything2EverythingBootstrap.CreateDefault(); + + [Fact] + public void GetAvailableOutputs_Png_DoesNotContain_Docx() + { + var engine = CreateEngine(); + var outputs = ContextMenuRegistrar.GetAvailableOutputs(engine, ".png"); + + Assert.DoesNotContain(outputs, o => o.Ext == ".docx"); + Assert.Contains(outputs, o => o.Ext == ".jpg"); + Assert.Contains(outputs, o => o.Ext == ".webp"); + Assert.Contains(outputs, o => o.Ext == ".pdf"); + } + + [Fact] + public void GetAvailableOutputs_Video_Contains_Mp4_And_Mp3() + { + var engine = CreateEngine(); + var outputs = ContextMenuRegistrar.GetAvailableOutputs(engine, ".mkv"); + + // 동영상 우클릭 메뉴에 MP4 변환 및 MP3 음원 추출이 노출되어야 한다. + Assert.Contains(outputs, o => o.Ext == ".mp4"); + Assert.Contains(outputs, o => o.Ext == ".mp3"); + } + + [Fact] + public void GetAvailableOutputs_Csv_Contains_Xlsx() + { + var engine = CreateEngine(); + var outputs = ContextMenuRegistrar.GetAvailableOutputs(engine, ".csv"); + + // CSV 표 데이터 우클릭 메뉴에 Excel(.xlsx) 변환이 노출되어야 한다. + Assert.Contains(outputs, o => o.Ext == ".xlsx"); + } + + [Fact] + public void GetAvailableOutputs_Audio_DoesNotContain_Gif_Or_Video() + { + var engine = CreateEngine(); + var outputs = ContextMenuRegistrar.GetAvailableOutputs(engine, ".mp3"); + + // 오디오 파일 우클릭 메뉴에 GIF나 비디오가 노출되지 않아야 한다. + Assert.DoesNotContain(outputs, o => o.Ext == ".gif"); + Assert.DoesNotContain(outputs, o => o.Ext == ".mp4"); + } +} diff --git a/src/Everything2Everything.Tests/MediaNegotiationTests.cs b/src/Everything2Everything.Tests/MediaNegotiationTests.cs new file mode 100644 index 0000000..9c26cb3 --- /dev/null +++ b/src/Everything2Everything.Tests/MediaNegotiationTests.cs @@ -0,0 +1,154 @@ +using System.IO; +using System.Threading.Tasks; +using Everything2Everything.Core; +using Everything2Everything.Core.Filters; +using Everything2Everything.Core.Providers; +using Xunit; + +namespace Everything2Everything.Tests; + +/// +/// 미디어 전환 가능 Filter 네고시에이팅 기능 테스트 스위트 (TDD). +/// PNG 등 이미지 입력에 대해 DOCX/Word 메뉴 및 변환이 노출/허용되지 않도록 보장한다. +/// +public class MediaNegotiationTests +{ + private static ConversionEngine CreateEngine() => Everything2EverythingBootstrap.CreateDefault(); + private static ProviderRegistry CreateRegistry() => CreateEngine().Providers; + + [Theory] + [InlineData(".png")] + [InlineData(".jpg")] + [InlineData(".jpeg")] + [InlineData(".webp")] + [InlineData(".gif")] + [InlineData(".bmp")] + public void OutputsForInput_ImageFormats_DoNotContain_Docx_Or_OtherRichDocuments(string imageExt) + { + var reg = CreateRegistry(); + var outputs = reg.OutputsForInput(imageExt); + + // 이미지 입력은 Word(.docx, .doc), HWP, HTML, Markdown 등의 서식 문서로 변환될 수 없다. + Assert.DoesNotContain(".docx", outputs); + Assert.DoesNotContain(".doc", outputs); + Assert.DoesNotContain(".hwp", outputs); + Assert.DoesNotContain(".hwpx", outputs); + Assert.DoesNotContain(".html", outputs); + Assert.DoesNotContain(".md", outputs); + } + + [Fact] + public void AvailableOutputsForFiles_PngQueue_DoesNotContain_Docx() + { + var reg = CreateRegistry(); + var available = reg.AvailableOutputsForFiles(new[] { "C:\\test\\sample.png" }); + + // PNG 큐에 대해 UI 및 컨텍스트 메뉴용 공통 출력 목록에 .docx가 포함되지 않아야 한다. + Assert.DoesNotContain(".docx", available); + } + + [Fact] + public void OutputsForInput_Png_Maintains_ValidOutputs() + { + var reg = CreateRegistry(); + var outputs = reg.OutputsForInput(".png"); + + // 이미지 간 변환은 정상 유지 + Assert.Contains(".jpg", outputs); + Assert.Contains(".webp", outputs); + + // 이미지 캡슐화 PDF는 정상 유지 + Assert.Contains(".pdf", outputs); + + // OCR 텍스트 추출은 정상 유지 + Assert.Contains(".txt", outputs); + } + + [Fact] + public void OutputsForInput_Docx_Maintains_DocumentAndImageOutputs() + { + var reg = CreateRegistry(); + var outputs = reg.OutputsForInput(".docx"); + + // DOCX는 PDF 변환 및 렌더링 이미지 출력이 가능해야 함 + Assert.Contains(".pdf", outputs); + Assert.Contains(".txt", outputs); + } + + [Fact] + public async Task ConvertOneAsync_PngToDocx_FailsWithUnsupportedMediaConversion() + { + var engine = CreateEngine(); + var tempPng = Path.Combine(Path.GetTempPath(), $"test_{Guid.NewGuid():N}.png"); + await File.WriteAllBytesAsync(tempPng, new byte[] { 0x89, 0x50, 0x4E, 0x47 }); // minimal png header + + try + { + var result = await engine.ConvertOneAsync(tempPng, ".docx", new ConvertOptions()); + Assert.Equal(ConvertStatus.Failed, result.Status); + Assert.Contains("미디어 전환", result.Message); + } + finally + { + if (File.Exists(tempPng)) File.Delete(tempPng); + } + } + + [Fact] + public void ConversionGraph_FindBestPath_PngToDocx_ReturnsNull() + { + var graph = CreateEngine().Providers.Graph; + var path = graph.FindBestPath(".png", ".docx", maxHops: 3); + Assert.Null(path); + } + + [Theory] + [InlineData(".png", ".docx", false)] + [InlineData(".jpg", ".docx", false)] + [InlineData(".png", ".hwp", false)] + [InlineData(".png", ".jpg", true)] + [InlineData(".png", ".pdf", true)] + [InlineData(".png", ".txt", true)] + [InlineData(".docx", ".pdf", true)] + [InlineData(".docx", ".png", true)] + [InlineData(".mp4", ".docx", false)] + [InlineData(".csv", ".docx", false)] + public void MediaConversionNegotiator_ValidatesCompatibility(string input, string output, bool expectedAllowed) + { + var allowed = MediaConversionNegotiator.CanConvert(input, output); + Assert.Equal(expectedAllowed, allowed); + } + + [Theory] + [InlineData(".mp3", ".gif", false)] + [InlineData(".mp3", ".png", false)] + [InlineData(".mp3", ".jpg", false)] + [InlineData(".mp3", ".mp4", false)] + [InlineData(".wav", ".mkv", false)] + [InlineData(".mp3", ".wav", true)] + [InlineData(".flac", ".mp3", true)] + [InlineData(".wav", ".aac", true)] + [InlineData(".mp4", ".mkv", true)] + [InlineData(".mp4", ".mp3", true)] + [InlineData(".mp4", ".gif", true)] + [InlineData(".mp4", ".png", true)] + [InlineData(".mp4", ".jpg", true)] + [InlineData(".mkv", ".webp", true)] + [InlineData(".mp4", ".pdf", false)] + [InlineData(".mp4", ".docx", false)] + [InlineData(".mp4", ".csv", false)] + public void MediaConversionNegotiator_DistinguishesVideoAndAudio(string input, string output, bool expectedAllowed) + { + var allowed = MediaConversionNegotiator.CanConvert(input, output); + Assert.Equal(expectedAllowed, allowed); + } + + [Fact] + public void AvailableOutputsForFiles_IncompatibleMixedQueue_ReturnsEmpty() + { + var reg = CreateRegistry(); + // mp3와 csv는 공통 변환 출력이 전혀 없다 + var available = reg.AvailableOutputsForFiles(new[] { "C:\\music.mp3", "C:\\data.csv" }); + Assert.Empty(available); + } +}