1
0
Fork 0
Everything2Everything/src/Everything2Everything.Core/ConversionEngine.cs
Yun Chan e3ef59e009 feat(P1): 변환 그래프 엔진 + 멀티홉 경로탐색 + PDF 압축
ProviderRegistry를 ConversionGraph로 승격하고 ConversionEngine이 자체 Dijkstra로 멀티홉 경로를 자동 합성하도록 전환. 손실을 LossClass 가중치로 SSOT화.

- ConversionGraph: (node,hop) 라벨링 Dijkstra로 홉 제약 최단경로 (일반 Dijkstra의 hops 오염 false negative 회피)
- ConversionEngine.ExecuteChainAsync: 멀티홉 체이닝, 중간 다중출력 거부, 마지막 홉 Status 존중
- PdfToolProvider: pdf->pdf 구조 압축 (PDFsharp, Save 원자화)
- PairsFromMatrix 자기쌍 제외 (png->png 재인코딩 회귀 방지), PDF->이미지 Rasterize 표시
- xUnit 테스트 프로젝트 신설 + 그래프/회귀 14개 통과
- 적대적 병렬 리뷰(27 agents)로 확정된 13건 중 정확성 핵심 수정

SSOT: P1 done
2026-06-01 12:48:58 +09:00

376 lines
16 KiB
C#

using Everything2Everything.Core.Providers;
using ImageMagick;
namespace Everything2Everything.Core;
public enum BatchMode
{
Independent,
CombineToSingle,
}
public sealed class ConversionEngine
{
private static readonly HashSet<string> CombinableInputs = new(StringComparer.OrdinalIgnoreCase)
{
".png", ".jpg", ".jpeg", ".jpe", ".webp", ".avif", ".bmp",
".tif", ".tiff", ".gif", ".heic", ".heif", ".psd",
};
private static readonly HashSet<string> CombinableOutputs = new(StringComparer.OrdinalIgnoreCase)
{
".pdf", ".tif", ".tiff", ".gif",
};
private readonly ProviderRegistry _registry;
public ConversionEngine(ProviderRegistry registry)
{
_registry = registry;
}
public ProviderRegistry Providers => _registry;
public async Task<IReadOnlyList<ConvertResult>> ConvertManyAsync(
IEnumerable<string> sources,
string outputExtension,
ConvertOptions options,
IProgress<ConvertProgress>? progress = null,
BatchMode batchMode = BatchMode.Independent,
CancellationToken cancellationToken = default)
{
var sourceList = sources.ToList();
if (batchMode == BatchMode.CombineToSingle && sourceList.Count > 1)
{
if (IsCombineSupported(sourceList, outputExtension, out var unsupportedReason))
{
var combined = await CombineAsync(sourceList, outputExtension, options, progress, cancellationToken)
.ConfigureAwait(false);
return new[] { combined };
}
return new[] { ConvertResult.Fail(sourceList[0], unsupportedReason ?? "단일 파일 결합을 지원하지 않습니다.") };
}
var results = new List<ConvertResult>(sourceList.Count);
for (var i = 0; i < sourceList.Count; i++)
{
cancellationToken.ThrowIfCancellationRequested();
var source = sourceList[i];
progress?.Report(new ConvertProgress(i, sourceList.Count, source, 0));
var result = await ConvertOneAsync(source, outputExtension, options,
new Progress<double>(p => progress?.Report(new ConvertProgress(i, sourceList.Count, source, p))),
cancellationToken).ConfigureAwait(false);
results.Add(result);
progress?.Report(new ConvertProgress(i + 1, sourceList.Count, source, 1));
}
return results;
}
public async Task<ConvertResult> ConvertOneAsync(
string sourcePath,
string outputExtension,
ConvertOptions options,
IProgress<double>? progress = null,
CancellationToken cancellationToken = default)
{
if (!File.Exists(sourcePath))
return ConvertResult.Fail(sourcePath, "파일을 찾을 수 없습니다.");
var output = ConversionPair.Normalize(outputExtension);
var inputExt = ConversionPair.Normalize(Path.GetExtension(sourcePath));
// 그래프 경로 탐색: 직접 엣지가 있으면 1홉, 없으면 손실 가중치 기반 멀티홉을 자동 합성.
var maxHops = options.AllowMultiHop ? Math.Max(1, options.MaxHops) : 1;
var path = _registry.Graph.FindBestPath(inputExt, output, maxHops, !options.AvoidLossy);
if (path is null || path.Count == 0)
{
if (string.Equals(inputExt, output, StringComparison.OrdinalIgnoreCase))
return ConvertResult.Skip(sourcePath, "입력과 출력 형식이 동일해 변환이 필요하지 않습니다.");
var available = _registry.OutputsForFile(sourcePath);
var hint = available.Count > 0
? $" 가능한 출력: {string.Join(", ", available)}"
: string.Empty;
return ConvertResult.Fail(sourcePath, $"{inputExt} → {output} 변환을 지원하지 않습니다.{hint}");
}
return await ExecuteChainAsync(sourcePath, output, path, options, progress, cancellationToken)
.ConfigureAwait(false);
}
/// <summary>
/// 변환 경로(1홉 또는 멀티홉)를 순차 실행한다. 중간 산출물은 임시 작업폴더에 체이닝하고
/// 마지막 홉만 실제 출력 폴더에 쓴다. 각 홉 시작 전 Provider 가용성을 점검한다.
/// </summary>
private async Task<ConvertResult> ExecuteChainAsync(
string sourcePath,
string finalOutputExtension,
IReadOnlyList<ConversionGraph.Edge> path,
ConvertOptions options,
IProgress<double>? progress,
CancellationToken cancellationToken)
{
foreach (var edge in path)
{
var availability = await edge.Provider.CheckAvailabilityAsync(cancellationToken).ConfigureAwait(false);
if (!availability.IsReady)
{
var missing = availability.MissingDependencies?.Select(d => d.Name) ?? Array.Empty<string>();
var detail = availability.Reason ?? "필수 의존성이 준비되지 않았습니다.";
if (missing.Any()) detail += $" (필요: {string.Join(", ", missing)})";
return ConvertResult.Fail(sourcePath, detail);
}
}
// 단일 홉 — 기존 동작과 동일 (출력 폴더 해결 후 직접 위임)
if (path.Count == 1)
{
var outputDir = ResolveOutputDirectory(sourcePath, finalOutputExtension, options);
Directory.CreateDirectory(outputDir);
try
{
return await path[0].Provider
.ConvertAsync(sourcePath, outputDir, finalOutputExtension, options, progress, cancellationToken)
.ConfigureAwait(false);
}
catch (OperationCanceledException) { throw; }
catch (Exception ex) { return ConvertResult.Fail(sourcePath, ex.Message, ex); }
}
// 멀티홉 — 중간 산출물은 임시 폴더, 마지막만 실제 출력
var workRoot = Path.Combine(Path.GetTempPath(), "e2e_" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(workRoot);
try
{
var current = sourcePath;
for (var h = 0; h < path.Count; h++)
{
cancellationToken.ThrowIfCancellationRequested();
var edge = path[h];
var isLast = h == path.Count - 1;
var hopExt = edge.To;
var outDir = isLast
? ResolveOutputDirectory(sourcePath, finalOutputExtension, options)
: Path.Combine(workRoot, "h" + h.ToString());
Directory.CreateDirectory(outDir);
var hopIndex = h;
var hopProgress = new Progress<double>(p =>
progress?.Report((hopIndex + Math.Clamp(p, 0, 1)) / path.Count));
ConvertResult result;
try
{
result = await edge.Provider
.ConvertAsync(current, outDir, hopExt, options, hopProgress, cancellationToken)
.ConfigureAwait(false);
}
catch (OperationCanceledException) { throw; }
catch (Exception ex)
{
return ConvertResult.Fail(sourcePath,
$"{h + 1}/{path.Count}단계 ({edge.From} → {edge.To}) 실패: {ex.Message}", ex);
}
if (isLast)
{
// 마지막 홉은 상태를 그대로 존중 (정상 Skip을 Fail로 둔갑시키지 않음)
return result.Status switch
{
ConvertStatus.Success => ConvertResult.Ok(sourcePath, result.OutputPaths),
ConvertStatus.Skipped => ConvertResult.Skip(sourcePath, result.Message ?? "건너뛰었습니다."),
_ => ConvertResult.Fail(sourcePath,
$"{h + 1}/{path.Count}단계 ({edge.From} → {edge.To}) 실패: {result.Message ?? " ."}", result.Error),
};
}
// 중간 홉은 단일 산출물이어야 다음 홉으로 체이닝할 수 있다.
if (result.Status != ConvertStatus.Success || result.OutputPaths.Count == 0)
{
return ConvertResult.Fail(sourcePath,
$"{h + 1}/{path.Count}단계 ({edge.From} → {edge.To}) 실패: {result.Message ?? " ."}");
}
if (result.OutputPaths.Count > 1)
{
// 다중 산출물(예: PDF→페이지별 이미지)을 중간 홉으로 두면 나머지가 유실된다 — 명시적 거부.
return ConvertResult.Fail(sourcePath,
$"{h + 1}/{path.Count}단계 ({edge.From} → {edge.To})가 여러 파일을 생성해 자동 합성된 다음 단계로 이어갈 수 없습니다. 직접 변환 경로를 사용하거나 단계를 나눠 실행하세요.");
}
current = result.OutputPaths[0]; // 다음 홉의 입력
}
return ConvertResult.Fail(sourcePath, "변환 체인 실행에 실패했습니다.");
}
finally
{
try { Directory.Delete(workRoot, true); } catch { /* 임시 정리 실패 무시 */ }
}
}
public static bool CanCombine(string outputExtension)
=> CombinableOutputs.Contains(ConversionPair.Normalize(outputExtension));
public static bool CanCombineInput(string sourcePath)
=> CombinableInputs.Contains(Path.GetExtension(sourcePath).ToLowerInvariant());
private static bool IsCombineSupported(
IReadOnlyList<string> sources,
string outputExtension,
out string? unsupportedReason)
{
var outExt = ConversionPair.Normalize(outputExtension);
if (!CombinableOutputs.Contains(outExt))
{
unsupportedReason = $"단일 파일 결합은 {string.Join(", ", CombinableOutputs.OrderBy(e => e))}만 지원합니다.";
return false;
}
foreach (var path in sources)
{
if (!File.Exists(path))
{
unsupportedReason = $"파일을 찾을 수 없습니다: {path}";
return false;
}
var ext = Path.GetExtension(path).ToLowerInvariant();
if (!CombinableInputs.Contains(ext))
{
unsupportedReason = $"단일 파일 결합은 이미지 입력만 지원합니다 (지원 외: {ext}).";
return false;
}
}
unsupportedReason = null;
return true;
}
private async Task<ConvertResult> CombineAsync(
IReadOnlyList<string> sources,
string outputExtension,
ConvertOptions options,
IProgress<ConvertProgress>? progress,
CancellationToken cancellationToken)
{
var outExt = ConversionPair.Normalize(outputExtension);
var firstSource = sources[0];
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 });
}
catch (OperationCanceledException)
{
throw;
}
catch (Exception ex)
{
return ConvertResult.Fail(firstSource, ex.Message, ex);
}
}
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))
?? throw new InvalidOperationException("소스 경로에서 폴더를 결정할 수 없습니다.");
return options.OutputLocation switch
{
OutputLocation.SameFolderAsSource => sourceDir,
OutputLocation.Custom => string.IsNullOrWhiteSpace(options.CustomOutputDirectory)
? sourceDir
: options.CustomOutputDirectory!,
_ => Path.Combine(sourceDir,
Path.GetFileNameWithoutExtension(sourcePath) + ResolveSubfolderSuffix(outputExtension, options)),
};
}
private static string ResolveSubfolderSuffix(string outputExtension, ConvertOptions options)
{
if (!string.IsNullOrWhiteSpace(options.SubfolderSuffix) && options.SubfolderSuffix != "_converted")
return options.SubfolderSuffix;
var ext = outputExtension.TrimStart('.').ToLowerInvariant();
return string.IsNullOrEmpty(ext) ? "_converted" : "_" + ext;
}
}
public sealed record ConvertProgress(int Index, int Total, string CurrentPath, double FileProgress);