1
0
Fork 0

refactor(P3): combine을 IMultiInputConverter로 추출 — 엔진 ImageMagick 누수 봉합

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<IMultiInputConverter>()로 결합기를 찾아 위임하는 얇은 코드로 축소.
  출력 디렉터리 결정만 엔진이 수행, 파일명·인코딩·쓰기는 결합기가 담당.
- UI 무변경: 정적 CanCombine/CanCombineInput(순수 메타데이터, ImageMagick 무관)은 엔진에 그대로 유지.
- 신규 테스트: MagickProvider가 IMultiInputConverter로 발견되는 seam 잠금.

P1 combine 골든마스터(프레임수·NoCompression·치수·평균색 + PDF 헤더)가 추출 후에도 그린 = 동작 동일성 입증.
70개 테스트 전부 그린, 빌드 0경고/0오류.
This commit is contained in:
Yun Chan 2026-06-01 23:39:02 +09:00
parent 8419bf30ff
commit dad1c0bca3
4 changed files with 154 additions and 72 deletions

View file

@ -1,5 +1,4 @@
using Everything2Everything.Core.Providers;
using ImageMagick;
namespace Everything2Everything.Core;
@ -252,6 +251,10 @@ public sealed class ConversionEngine
return true;
}
/// <summary>
/// 결합(N→1)을 IMultiInputConverter에 위임한다. 엔진은 출력 디렉터리만 결정하고, 실제 이미지 라이브러리
/// 작업(파일명 해결·인코딩·쓰기)은 결합기 구현체가 수행한다 — 엔진은 ImageMagick에 의존하지 않는다.
/// </summary>
private async Task<ConvertResult> CombineAsync(
IReadOnlyList<string> 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<IMultiInputConverter>().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))

View file

@ -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<string> 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<ConvertResult> CombineAsync(
IReadOnlyList<string> sources,
string outputDirectory,
string outputExtension,
ConvertOptions options,
IProgress<ConvertProgress>? 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;
}
}
}
}

View file

@ -0,0 +1,24 @@
namespace Everything2Everything.Core.Providers;
/// <summary>
/// 여러 입력을 단일 출력으로 결합하는 변환기(N→1). 변환 그래프의 1입력→1출력 엣지로는 표현할 수 없는
/// '결합'을 1급 시민으로 다룬다. ConversionEngine은 이 인터페이스에 결합을 위임하므로 엔진 자체는
/// 특정 이미지 라이브러리(ImageMagick)에 의존하지 않는다(추상화 누수 봉합).
/// </summary>
public interface IMultiInputConverter
{
/// <summary>이 결합기가 해당 출력 확장자로의 결합을 지원하는가.</summary>
bool CanCombineTo(string outputExtension);
/// <summary>
/// 여러 소스를 <paramref name="outputDirectory"/> 안에 단일 산출물로 결합한다.
/// 출력 디렉터리 결정은 엔진이 수행하고, 파일명 해결·충돌 처리·실제 쓰기는 구현체가 담당한다.
/// </summary>
Task<ConvertResult> CombineAsync(
IReadOnlyList<string> sources,
string outputDirectory,
string outputExtension,
ConvertOptions options,
IProgress<ConvertProgress>? progress,
CancellationToken cancellationToken);
}

View file

@ -59,6 +59,20 @@ public class DependencyInjectionTests
Assert.Same(settings, sp.GetRequiredService<ISettingsStore>());
}
[Fact]
public void MagickProvider_IsDiscoverableAsMultiInputCombiner()
{
// P3: 엔진은 결합을 _registry.All.OfType<IMultiInputConverter>()로 찾는다.
// 이 seam이 깨지면(MagickProvider가 인터페이스 미구현/Scrutor 미등록) 결합이 전부 실패한다.
var reg = Everything2EverythingBootstrap.CreateDefault().Providers;
var combiners = reg.All.OfType<IMultiInputConverter>().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()
{