From dad1c0bca35bec8523958c637c7eac659b91d5ec Mon Sep 17 00:00:00 2001 From: Yun Chan Date: Mon, 1 Jun 2026 23:39:02 +0900 Subject: [PATCH] =?UTF-8?q?refactor(P3):=20combine=EC=9D=84=20IMultiInputC?= =?UTF-8?q?onverter=EB=A1=9C=20=EC=B6=94=EC=B6=9C=20=E2=80=94=20=EC=97=94?= =?UTF-8?q?=EC=A7=84=20ImageMagick=20=EB=88=84=EC=88=98=20=EB=B4=89?= =?UTF-8?q?=ED=95=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ConversionEngine이 더 이상 ImageMagick에 의존하지 않는다(추상화 누수 확정 봉합). 결합(N→1)을 변환 그래프의 1입력 엣지로 표현 못 하던 한계를 1급 인터페이스로 분리(Branch by Abstraction). - Core/Providers/IMultiInputConverter.cs 신규 — CanCombineTo + CombineAsync(N→1). - MagickProvider가 IMultiInputConverter 구현 — 엔진의 CombineAsync/LoadImageForCombine/ ApplyCombineEncoding 코드를 동작 그대로 이주(CombinableOutputs도 이관). - ConversionEngine: using ImageMagick 제거(코드 참조 0건, grep 검증 — 주석만 잔존), CombineAsync는 _registry.All.OfType()로 결합기를 찾아 위임하는 얇은 코드로 축소. 출력 디렉터리 결정만 엔진이 수행, 파일명·인코딩·쓰기는 결합기가 담당. - UI 무변경: 정적 CanCombine/CanCombineInput(순수 메타데이터, ImageMagick 무관)은 엔진에 그대로 유지. - 신규 테스트: MagickProvider가 IMultiInputConverter로 발견되는 seam 잠금. P1 combine 골든마스터(프레임수·NoCompression·치수·평균색 + PDF 헤더)가 추출 후에도 그린 = 동작 동일성 입증. 70개 테스트 전부 그린, 빌드 0경고/0오류. --- .../ConversionEngine.cs | 83 ++------------ .../Converters/MagickProvider.cs | 105 +++++++++++++++++- .../Providers/IMultiInputConverter.cs | 24 ++++ .../DependencyInjectionTests.cs | 14 +++ 4 files changed, 154 insertions(+), 72 deletions(-) create mode 100644 src/Everything2Everything.Core/Providers/IMultiInputConverter.cs diff --git a/src/Everything2Everything.Core/ConversionEngine.cs b/src/Everything2Everything.Core/ConversionEngine.cs index 0e718af..f73a966 100644 --- a/src/Everything2Everything.Core/ConversionEngine.cs +++ b/src/Everything2Everything.Core/ConversionEngine.cs @@ -1,5 +1,4 @@ using Everything2Everything.Core.Providers; -using ImageMagick; namespace Everything2Everything.Core; @@ -252,6 +251,10 @@ public sealed class ConversionEngine return true; } + /// + /// 결합(N→1)을 IMultiInputConverter에 위임한다. 엔진은 출력 디렉터리만 결정하고, 실제 이미지 라이브러리 + /// 작업(파일명 해결·인코딩·쓰기)은 결합기 구현체가 수행한다 — 엔진은 ImageMagick에 의존하지 않는다. + /// private async Task CombineAsync( IReadOnlyList sources, string outputExtension, @@ -261,37 +264,19 @@ public sealed class ConversionEngine { var outExt = ConversionPair.Normalize(outputExtension); var firstSource = sources[0]; + + var combiner = _registry.All.OfType().FirstOrDefault(c => c.CanCombineTo(outExt)); + if (combiner is null) + return ConvertResult.Fail(firstSource, $"{outExt} 단일 파일 결합을 지원하는 변환기가 없습니다."); + var outputDir = ResolveOutputDirectory(firstSource, outExt, options); Directory.CreateDirectory(outputDir); - var baseName = sources.Count == 1 - ? Path.GetFileNameWithoutExtension(firstSource) - : $"combined_{sources.Count}files_{DateTime.Now:yyyyMMdd_HHmmss}"; - - var path = OutputPathHelper.ResolveOutputPath(outputDir, baseName, null, outExt, options.OnCollision); - if (OutputPathHelper.ShouldSkip(path, options.OnCollision)) - return ConvertResult.Skip(firstSource, "기존 파일이 있어 건너뜁니다."); - try { - await Task.Run(() => - { - using var collection = new MagickImageCollection(); - for (var i = 0; i < sources.Count; i++) - { - cancellationToken.ThrowIfCancellationRequested(); - progress?.Report(new ConvertProgress(i, sources.Count, sources[i], 0.5)); - var image = LoadImageForCombine(sources[i], outExt, options); - collection.Add(image); - progress?.Report(new ConvertProgress(i, sources.Count, sources[i], 1)); - } - - ApplyCombineEncoding(collection, outExt, options); - collection.Write(path); - }, cancellationToken).ConfigureAwait(false); - - progress?.Report(new ConvertProgress(sources.Count, sources.Count, path, 1)); - return ConvertResult.Ok(firstSource, new[] { path }); + return await combiner + .CombineAsync(sources, outputDir, outExt, options, progress, cancellationToken) + .ConfigureAwait(false); } catch (OperationCanceledException) { @@ -303,50 +288,6 @@ public sealed class ConversionEngine } } - private static MagickImage LoadImageForCombine(string sourcePath, string outputExtension, ConvertOptions options) - { - var image = new MagickImage(sourcePath); - try { image.AutoOrient(); } catch { } - - var alphaCapable = outputExtension is ".tif" or ".tiff"; - if ((!alphaCapable || options.FlattenTransparency) && image.HasAlpha) - { - image.BackgroundColor = new MagickColor(options.TransparencyBackground); - image.Alpha(AlphaOption.Remove); - image.Alpha(AlphaOption.Off); - } - - if (options.MaxLongEdgePixels is int maxLong && maxLong > 0 - && (image.Width > (uint)maxLong || image.Height > (uint)maxLong)) - { - image.Resize(new MagickGeometry((uint)maxLong, (uint)maxLong) { IgnoreAspectRatio = false }); - } - - return image; - } - - private static void ApplyCombineEncoding(MagickImageCollection collection, string outputExtension, ConvertOptions options) - { - foreach (var image in collection) - { - switch (outputExtension) - { - case ".pdf": - image.Format = MagickFormat.Pdf; - break; - case ".tif": - case ".tiff": - image.Format = MagickFormat.Tiff; - if (!string.IsNullOrWhiteSpace(options.Tiff.Compression)) - image.Settings.SetDefine(MagickFormat.Tiff, "compression", options.Tiff.Compression); - break; - case ".gif": - image.Format = MagickFormat.Gif; - break; - } - } - } - private static string ResolveOutputDirectory(string sourcePath, string outputExtension, ConvertOptions options) { var sourceDir = Path.GetDirectoryName(Path.GetFullPath(sourcePath)) diff --git a/src/Everything2Everything.Core/Converters/MagickProvider.cs b/src/Everything2Everything.Core/Converters/MagickProvider.cs index 5607c78..f422d9b 100644 --- a/src/Everything2Everything.Core/Converters/MagickProvider.cs +++ b/src/Everything2Everything.Core/Converters/MagickProvider.cs @@ -3,8 +3,14 @@ using ImageMagick; namespace Everything2Everything.Core.Converters; -public sealed class MagickProvider : IConverterProvider +public sealed class MagickProvider : IConverterProvider, IMultiInputConverter { + // 결합(N→1) 지원 매트릭스 — ConversionEngine에서 이주(추상화 누수 봉합). + private static readonly HashSet CombinableOutputs = new(StringComparer.OrdinalIgnoreCase) + { + ".pdf", ".tif", ".tiff", ".gif", + }; + private static readonly string[] SingleFrameInputs = { ".png", ".bmp", ".jpg", ".jpeg", ".jpe", ".webp", ".avif", ".psd", @@ -251,4 +257,101 @@ public sealed class MagickProvider : IConverterProvider foreach (var img in collection) ApplySingleEncoding(img, format, outputExtension, options); } + + // ── IMultiInputConverter (결합 N→1) — ConversionEngine에서 이주한 구현 ───────────────────────── + public bool CanCombineTo(string outputExtension) + => CombinableOutputs.Contains(ConversionPair.Normalize(outputExtension)); + + public Task CombineAsync( + IReadOnlyList sources, + string outputDirectory, + string outputExtension, + ConvertOptions options, + IProgress? progress, + CancellationToken cancellationToken) + { + var outExt = ConversionPair.Normalize(outputExtension); + var firstSource = sources[0]; + var baseName = sources.Count == 1 + ? Path.GetFileNameWithoutExtension(firstSource) + : $"combined_{sources.Count}files_{DateTime.Now:yyyyMMdd_HHmmss}"; + + var path = OutputPathHelper.ResolveOutputPath(outputDirectory, baseName, null, outExt, options.OnCollision); + if (OutputPathHelper.ShouldSkip(path, options.OnCollision)) + return Task.FromResult(ConvertResult.Skip(firstSource, "기존 파일이 있어 건너뜁니다.")); + + return Task.Run(() => + { + try + { + using var collection = new MagickImageCollection(); + for (var i = 0; i < sources.Count; i++) + { + cancellationToken.ThrowIfCancellationRequested(); + progress?.Report(new ConvertProgress(i, sources.Count, sources[i], 0.5)); + var image = LoadImageForCombine(sources[i], outExt, options); + collection.Add(image); + progress?.Report(new ConvertProgress(i, sources.Count, sources[i], 1)); + } + + ApplyCombineEncoding(collection, outExt, options); + collection.Write(path); + + progress?.Report(new ConvertProgress(sources.Count, sources.Count, path, 1)); + return ConvertResult.Ok(firstSource, new[] { path }); + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception ex) + { + return ConvertResult.Fail(firstSource, ex.Message, ex); + } + }, cancellationToken); + } + + private static MagickImage LoadImageForCombine(string sourcePath, string outputExtension, ConvertOptions options) + { + var image = new MagickImage(sourcePath); + try { image.AutoOrient(); } catch { } + + var alphaCapable = outputExtension is ".tif" or ".tiff"; + if ((!alphaCapable || options.FlattenTransparency) && image.HasAlpha) + { + image.BackgroundColor = new MagickColor(options.TransparencyBackground); + image.Alpha(AlphaOption.Remove); + image.Alpha(AlphaOption.Off); + } + + if (options.MaxLongEdgePixels is int maxLong && maxLong > 0 + && (image.Width > (uint)maxLong || image.Height > (uint)maxLong)) + { + image.Resize(new MagickGeometry((uint)maxLong, (uint)maxLong) { IgnoreAspectRatio = false }); + } + + return image; + } + + private static void ApplyCombineEncoding(MagickImageCollection collection, string outputExtension, ConvertOptions options) + { + foreach (var image in collection) + { + switch (outputExtension) + { + case ".pdf": + image.Format = MagickFormat.Pdf; + break; + case ".tif": + case ".tiff": + image.Format = MagickFormat.Tiff; + if (!string.IsNullOrWhiteSpace(options.Tiff.Compression)) + image.Settings.SetDefine(MagickFormat.Tiff, "compression", options.Tiff.Compression); + break; + case ".gif": + image.Format = MagickFormat.Gif; + break; + } + } + } } diff --git a/src/Everything2Everything.Core/Providers/IMultiInputConverter.cs b/src/Everything2Everything.Core/Providers/IMultiInputConverter.cs new file mode 100644 index 0000000..976804a --- /dev/null +++ b/src/Everything2Everything.Core/Providers/IMultiInputConverter.cs @@ -0,0 +1,24 @@ +namespace Everything2Everything.Core.Providers; + +/// +/// 여러 입력을 단일 출력으로 결합하는 변환기(N→1). 변환 그래프의 1입력→1출력 엣지로는 표현할 수 없는 +/// '결합'을 1급 시민으로 다룬다. ConversionEngine은 이 인터페이스에 결합을 위임하므로 엔진 자체는 +/// 특정 이미지 라이브러리(ImageMagick)에 의존하지 않는다(추상화 누수 봉합). +/// +public interface IMultiInputConverter +{ + /// 이 결합기가 해당 출력 확장자로의 결합을 지원하는가. + bool CanCombineTo(string outputExtension); + + /// + /// 여러 소스를 안에 단일 산출물로 결합한다. + /// 출력 디렉터리 결정은 엔진이 수행하고, 파일명 해결·충돌 처리·실제 쓰기는 구현체가 담당한다. + /// + Task CombineAsync( + IReadOnlyList sources, + string outputDirectory, + string outputExtension, + ConvertOptions options, + IProgress? progress, + CancellationToken cancellationToken); +} diff --git a/src/Everything2Everything.Tests/DependencyInjectionTests.cs b/src/Everything2Everything.Tests/DependencyInjectionTests.cs index a0969f5..ac0d403 100644 --- a/src/Everything2Everything.Tests/DependencyInjectionTests.cs +++ b/src/Everything2Everything.Tests/DependencyInjectionTests.cs @@ -59,6 +59,20 @@ public class DependencyInjectionTests Assert.Same(settings, sp.GetRequiredService()); } + [Fact] + public void MagickProvider_IsDiscoverableAsMultiInputCombiner() + { + // P3: 엔진은 결합을 _registry.All.OfType()로 찾는다. + // 이 seam이 깨지면(MagickProvider가 인터페이스 미구현/Scrutor 미등록) 결합이 전부 실패한다. + var reg = Everything2EverythingBootstrap.CreateDefault().Providers; + var combiners = reg.All.OfType().ToList(); + Assert.NotEmpty(combiners); + Assert.Contains(combiners, c => c.CanCombineTo(".pdf")); + Assert.Contains(combiners, c => c.CanCombineTo(".tif")); + Assert.Contains(combiners, c => c.CanCombineTo(".gif")); + Assert.DoesNotContain(combiners, c => c.CanCombineTo(".png")); // png은 결합 출력이 아님 + } + [Fact] public void CreateDefault_Facade_StillWorks() {