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()
{