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:
parent
8419bf30ff
commit
dad1c0bca3
4 changed files with 154 additions and 72 deletions
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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()
|
||||
{
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue