1
0
Fork 0

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)
This commit is contained in:
Yun Chan 2026-09-03 18:47:45 +09:00
parent 07d7dfeb40
commit 28624c386e
7 changed files with 410 additions and 29 deletions

View file

@ -4,7 +4,7 @@ using Microsoft.Win32;
namespace Everything2Everything.App.Shell; namespace Everything2Everything.App.Shell;
internal static class ContextMenuRegistrar public static class ContextMenuRegistrar
{ {
private const string MainVerb = "Everything2Everything"; private const string MainVerb = "Everything2Everything";
private const string MainLabel = "Everything2Everything으로 변환"; private const string MainLabel = "Everything2Everything으로 변환";
@ -25,8 +25,22 @@ internal static class ContextMenuRegistrar
(".gif", "GIF (.gif)", "10"), (".gif", "GIF (.gif)", "10"),
(".tif", "TIFF (.tif)", "11"), (".tif", "TIFF (.tif)", "11"),
(".bmp", "BMP (.bmp)", "12"), (".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) public static void Register(ConversionEngine engine)
{ {
var exe = GetAppExecutablePath(); var exe = GetAppExecutablePath();
@ -34,14 +48,7 @@ internal static class ContextMenuRegistrar
foreach (var ext in CollectInputExtensions(engine)) foreach (var ext in CollectInputExtensions(engine))
{ {
var outputs = engine.Providers.OutputsForInput(ext) var availableOutputs = GetAvailableOutputs(engine, ext);
.Select(o => o.ToLowerInvariant())
.ToHashSet();
var availableOutputs = PopularOutputs
.Where(p => outputs.Contains(p.Ext) && !string.Equals(p.Ext, ext, StringComparison.OrdinalIgnoreCase))
.ToList();
if (availableOutputs.Count == 0) continue; if (availableOutputs.Count == 0) continue;
WriteCascade(ext, exe, icon, availableOutputs); WriteCascade(ext, exe, icon, availableOutputs);

View file

@ -325,6 +325,11 @@ public partial class MainWindow : Wpf.Ui.Controls.FluentWindow
ProcessQueueButton.Content = "Idle — drop files to begin"; ProcessQueueButton.Content = "Idle — drop files to begin";
ProcessQueueButton.IsEnabled = false; ProcessQueueButton.IsEnabled = false;
} }
else if (string.IsNullOrEmpty(SelectedOutputExtension))
{
ProcessQueueButton.Content = "변환 불가 (공통 형식 없음)";
ProcessQueueButton.IsEnabled = false;
}
else else
{ {
ProcessQueueButton.Content = $"Process Queue ({count})"; ProcessQueueButton.Content = $"Process Queue ({count})";
@ -1128,33 +1133,49 @@ public partial class MainWindow : Wpf.Ui.Controls.FluentWindow
_activeQueue.Select(q => q.SourcePath).ToList()); _activeQueue.Select(q => q.SourcePath).ToList());
var visible = AllFormats.Where(f => available.Contains(f.Extension)).ToList(); var visible = AllFormats.Where(f => available.Contains(f.Extension)).ToList();
if (visible.Count == 0) var hasCommonFormats = visible.Count > 0;
visible = AllFormats.Where(f => f.Extension == ".jpg").ToList();
var keepExt = SelectedOutputExtension; string? keepExt = null;
if (keepExt is null || !visible.Any(v => string.Equals(v.Extension, keepExt, StringComparison.OrdinalIgnoreCase))) if (hasCommonFormats)
keepExt = visible[0].Extension; {
keepExt = SelectedOutputExtension;
if (keepExt is null || !visible.Any(v => string.Equals(v.Extension, keepExt, StringComparison.OrdinalIgnoreCase)))
keepExt = visible[0].Extension;
}
_suppressFormatChanged = true; _suppressFormatChanged = true;
try try
{ {
OutputFormatCombo.Items.Clear(); 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 OutputFormatCombo.Items.Add(new ComboBoxItem
{ {
Content = BuildFormatItemContent(f), Content = "(공통 변환 형식 없음)",
Tag = f.Extension, Tag = string.Empty,
IsEnabled = false,
}); });
} OutputFormatCombo.SelectedIndex = 0;
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;
}
} }
} }
finally finally
@ -1167,12 +1188,15 @@ public partial class MainWindow : Wpf.Ui.Controls.FluentWindow
UpdateQualityPanelForFormat(keepExt); UpdateQualityPanelForFormat(keepExt);
UpdateOutputDestHint(keepExt); UpdateOutputDestHint(keepExt);
UpdateCombineState(keepExt); UpdateCombineState(keepExt);
UpdateProcessQueueButton();
if (OutputFormatHint is not null) if (OutputFormatHint is not null)
{ {
OutputFormatHint.Text = _activeQueue.Count == 0 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) private void UpdateOutputFormatBadge(string? extension)
{ {
if (OutputFormatBadge is null || OutputFormatBadgeText is null) return; if (OutputFormatBadge is null || OutputFormatBadgeText is null) return;
var info = AllFormats.FirstOrDefault(f => var info = AllFormats.FirstOrDefault(f =>
string.Equals(f.Extension, extension, StringComparison.OrdinalIgnoreCase)); 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; OutputFormatBadgeText.Text = info.BadgeText;
var resource = TryFindResource(info.ColorResource); var resource = TryFindResource(info.ColorResource);

View file

@ -1,3 +1,4 @@
using Everything2Everything.Core.Filters;
using Everything2Everything.Core.Providers; using Everything2Everything.Core.Providers;
namespace Everything2Everything.Core; namespace Everything2Everything.Core;
@ -106,6 +107,11 @@ public sealed class ConversionEngine
var output = ConversionPair.Normalize(outputExtension); var output = ConversionPair.Normalize(outputExtension);
var inputExt = ConversionPair.Normalize(Path.GetExtension(sourcePath)); var inputExt = ConversionPair.Normalize(Path.GetExtension(sourcePath));
if (!MediaConversionNegotiator.CanConvert(inputExt, output))
{
return ConvertResult.Fail(sourcePath, $"{inputExt} → {output} 변환은 지원하지 않는 미디어 전환입니다.");
}
// 그래프 경로 탐색: 직접 엣지가 있으면 1홉, 없으면 손실 가중치 기반 멀티홉을 자동 합성. // 그래프 경로 탐색: 직접 엣지가 있으면 1홉, 없으면 손실 가중치 기반 멀티홉을 자동 합성.
var maxHops = options.AllowMultiHop ? Math.Max(1, options.MaxHops) : 1; var maxHops = options.AllowMultiHop ? Math.Max(1, options.MaxHops) : 1;
var path = _registry.Graph.FindBestPath(inputExt, output, maxHops, !options.AvoidLossy); var path = _registry.Graph.FindBestPath(inputExt, output, maxHops, !options.AvoidLossy);

View file

@ -0,0 +1,120 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using Everything2Everything.Core.Providers;
namespace Everything2Everything.Core.Filters;
/// <summary>
/// 미디어 카테고리(Image, Document, Media, Data) 간의 변환 호환성 및 출력 필터링을 협상(Negotiation)하는 도메인 서비스.
/// 이미지→Word(DOCX) 등 비정상적인 미디어 전이 노출 및 실행을 방지한다.
/// </summary>
public static class MediaConversionNegotiator
{
private static readonly HashSet<string> AllowedImageToDocumentExtensions = new(StringComparer.OrdinalIgnoreCase)
{
".pdf", // 이미지 문서 캡슐화 (PDF)
".txt", // OCR 텍스트 추출 (TXT)
};
private static readonly HashSet<string> VideoExtensions = new(StringComparer.OrdinalIgnoreCase)
{
".mp4", ".mkv", ".webm", ".mov", ".avi", ".m4v"
};
private static readonly HashSet<string> AudioExtensions = new(StringComparer.OrdinalIgnoreCase)
{
".mp3", ".wav", ".flac", ".aac", ".m4a", ".ogg", ".opus"
};
private static readonly HashSet<string> 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));
/// <summary>
/// 입력 확장자와 출력 확장자 간의 미디어 변환 가능 여부를 판정한다.
/// </summary>
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,
};
}
/// <summary>
/// 후보 출력 확장자 목록 중 입력 미디어와 호환되는 유효한 출력 확장자만 필터링하여 반환한다.
/// </summary>
public static IEnumerable<string> FilterAvailableOutputs(string inputExt, IEnumerable<string> candidateOutputs)
{
return candidateOutputs.Where(outExt => CanConvert(inputExt, outExt));
}
}

View file

@ -1,3 +1,5 @@
using Everything2Everything.Core.Filters;
namespace Everything2Everything.Core.Providers; namespace Everything2Everything.Core.Providers;
/// <summary> /// <summary>
@ -55,7 +57,7 @@ public sealed class ConversionGraph
foreach (var e in EdgesFrom(node)) foreach (var e in EdgesFrom(node))
{ {
if (!allowLossy && e.Loss == LossClass.Rasterize) continue; 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); result.Add(e.To);
var next = (e.To, hop + 1); var next = (e.To, hop + 1);
if (visited.Add(next)) if (visited.Add(next))
@ -75,6 +77,9 @@ public sealed class ConversionGraph
var goal = ConversionPair.Normalize(outputExt); var goal = ConversionPair.Normalize(outputExt);
if (maxHops < 1) maxHops = 1; if (maxHops < 1) maxHops = 1;
if (!MediaConversionNegotiator.CanConvert(start, goal))
return null;
if (string.Equals(start, goal, StringComparison.OrdinalIgnoreCase)) if (string.Equals(start, goal, StringComparison.OrdinalIgnoreCase))
{ {
Edge? best = null; Edge? best = null;

View file

@ -0,0 +1,59 @@
using System.Linq;
using Everything2Everything.App.Shell;
using Everything2Everything.Core;
using Xunit;
namespace Everything2Everything.Tests;
/// <summary>
/// 윈도우 탐색기 우클릭 컨텍스트 메뉴 네고시에이션 테스트 (TDD).
/// 동영상, 음성 추출, 엑셀 표 데이터 등 킬러 포맷의 정상 노출 및 비정상 변환(png→docx 등) 차단을 검증한다.
/// </summary>
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");
}
}

View file

@ -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;
/// <summary>
/// 미디어 전환 가능 Filter 네고시에이팅 기능 테스트 스위트 (TDD).
/// PNG 등 이미지 입력에 대해 DOCX/Word 메뉴 및 변환이 노출/허용되지 않도록 보장한다.
/// </summary>
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);
}
}