산출물
- ArchiveProvider(SharpCompress, 순수관리) — zip/7z/tar/gz/bz2
- DataProvider(Parquet.Net/ClosedXML/CsvHelper) — csv↔json↔xlsx↔parquet
- VectorProvider(Svg.Skia) — svg→png/jpg/webp/pdf, EPS는 Magick+Ghostscript
- PandocProvider(외부 CLI) — md/rst/latex/ipynb/epub 마크업 매트릭스, LibreOffice 겹침은 Priority 라우팅
- EbookProvider(Calibre ebook-convert, 외부) — epub↔mobi↔azw3↔pdf
- manifest 어댑터(ExternalProcessRunner 위 인자 템플릿): qpdf/gs 같은 단순 CLI 코드 없이 추가
diff --git a/src/Everything2Everything.Core/Converters/ExternalToolDetector.cs b/src/Everything2Everything.Core/Converters/ExternalToolDetector.cs
index c3812b9..189c2e7 100644
--- a/src/Everything2Everything.Core/Converters/ExternalToolDetector.cs
+++ b/src/Everything2Everything.Core/Converters/ExternalToolDetector.cs
@@ -49,6 +49,39 @@ internal static class ExternalToolDetector
}
}
+ ///
+ /// FFmpeg/ffprobe 바이너리 폴더를 찾는다. (1) 앱 전용 폴더(%LOCALAPPDATA%\Everything2Everything\ffmpeg),
+ /// (2) 시스템 PATH 순. ffmpeg.exe와 ffprobe.exe가 모두 있는 폴더만 유효.
+ ///
+ public static bool TryFindFfmpeg(out string ffmpegDirectory)
+ {
+ ffmpegDirectory = "";
+ var candidates = new List
();
+
+ var local = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData);
+ if (!string.IsNullOrEmpty(local))
+ candidates.Add(Path.Combine(local, "Everything2Everything", "ffmpeg"));
+
+ var pathEnv = Environment.GetEnvironmentVariable("PATH") ?? "";
+ foreach (var dir in pathEnv.Split(Path.PathSeparator))
+ if (!string.IsNullOrWhiteSpace(dir))
+ candidates.Add(dir.Trim());
+
+ foreach (var dir in candidates.Distinct())
+ {
+ try
+ {
+ if (File.Exists(Path.Combine(dir, "ffmpeg.exe")) && File.Exists(Path.Combine(dir, "ffprobe.exe")))
+ {
+ ffmpegDirectory = dir;
+ return true;
+ }
+ }
+ catch { /* 잘못된 경로 무시 */ }
+ }
+ return false;
+ }
+
public static bool IsH2OrestartInstalled()
{
try
diff --git a/src/Everything2Everything.Core/Converters/FfmpegProvider.cs b/src/Everything2Everything.Core/Converters/FfmpegProvider.cs
new file mode 100644
index 0000000..ffb2c28
--- /dev/null
+++ b/src/Everything2Everything.Core/Converters/FfmpegProvider.cs
@@ -0,0 +1,94 @@
+using Everything2Everything.Core.Providers;
+using FFMpegCore;
+
+namespace Everything2Everything.Core.Converters;
+
+///
+/// FFmpeg로 영상/오디오를 변환·트랜스코딩한다. ffmpeg/ffprobe가 없으면 NotReady로 비활성되며,
+/// 사용자가 바이너리를 PATH 또는 앱 폴더에 두면 자동 활성화된다(LGPL 분리 호출 — 본체에 번들하지 않음).
+///
+public sealed class FfmpegProvider : IConverterProvider
+{
+ private static readonly string[] Video = { ".mp4", ".mkv", ".webm", ".mov", ".avi", ".gif" };
+ private static readonly string[] Audio = { ".mp3", ".aac", ".m4a", ".opus", ".ogg", ".flac", ".wav" };
+
+ public ProviderCapability Capability { get; } = new(
+ Id: "ffmpeg",
+ DisplayName: "영상·오디오 (FFmpeg)",
+ SupportedConversions: BuildPairs(),
+ Status: ProviderStatus.RequiresExternal,
+ Summary: "FFmpeg로 영상/오디오를 변환·트랜스코딩하고 영상에서 오디오를 추출합니다. ffmpeg가 없으면 비활성됩니다.",
+ ExternalDependencies: new[]
+ {
+ new ExternalDependency(
+ Name: "FFmpeg (LGPL)",
+ Description: "ffmpeg.exe와 ffprobe.exe를 시스템 PATH 또는 %LOCALAPPDATA%\\Everything2Everything\\ffmpeg 에 두면 활성화됩니다. BtbN의 win64-lgpl-shared 빌드를 권장합니다.",
+ DownloadUrl: "https://github.com/BtbN/FFmpeg-Builds/releases",
+ IsRequired: true),
+ },
+ RoadmapNote: "하드웨어 인코더(nvenc/qsv) 우선·자동 다운로드·정밀 진행률은 후속 확장.");
+
+ private static IReadOnlyList BuildPairs()
+ {
+ var pairs = new List();
+ pairs.AddRange(ProviderCapability.PairsFromMatrix(Video, Video, LossClass.Recode));
+ pairs.AddRange(ProviderCapability.PairsFromMatrix(Audio, Audio, LossClass.Recode));
+ pairs.AddRange(ProviderCapability.PairsFromMatrix(Video, Audio, LossClass.Recode)); // 영상 → 오디오 추출
+ return pairs;
+ }
+
+ public Task CheckAvailabilityAsync(CancellationToken cancellationToken = default)
+ {
+ if (!ExternalToolDetector.TryFindFfmpeg(out _))
+ return Task.FromResult(ProviderAvailability.NotReady(
+ "FFmpeg가 설치되어 있지 않습니다. ffmpeg.exe/ffprobe.exe를 PATH 또는 %LOCALAPPDATA%\\Everything2Everything\\ffmpeg 에 두면 활성화됩니다.",
+ Capability.ExternalDependencies));
+ return Task.FromResult(ProviderAvailability.Ready);
+ }
+
+ public async Task ConvertAsync(
+ string sourcePath, string outputDirectory, string outputExtension,
+ ConvertOptions options, IProgress? progress, CancellationToken cancellationToken)
+ {
+ if (!ExternalToolDetector.TryFindFfmpeg(out var dir))
+ return ConvertResult.Fail(sourcePath, "FFmpeg를 찾을 수 없습니다.");
+
+ var outExt = ConversionPair.Normalize(outputExtension);
+ var baseName = Path.GetFileNameWithoutExtension(sourcePath);
+ var outPath = OutputPathHelper.ResolveOutputPath(outputDirectory, baseName, null, outExt, options.OnCollision);
+ if (OutputPathHelper.ShouldSkip(outPath, options.OnCollision))
+ return ConvertResult.Skip(sourcePath, "기존 파일이 있어 건너뜁니다.");
+
+ var ffOptions = new FFOptions { BinaryFolder = dir };
+ try
+ {
+ progress?.Report(0.05);
+
+ // 진행률 best-effort: ffprobe로 길이를 알면 시간 기반 보고
+ TimeSpan total = TimeSpan.Zero;
+ try { total = (await FFProbe.AnalyseAsync(sourcePath, ffOptions, cancellationToken).ConfigureAwait(false)).Duration; }
+ catch { /* 길이 미상이면 진행률 생략 */ }
+
+ var processor = FFMpegArguments
+ .FromFileInput(sourcePath)
+ .OutputToFile(outPath, overwrite: true)
+ .CancellableThrough(cancellationToken);
+
+ if (total > TimeSpan.Zero)
+ processor = processor.NotifyOnProgress(
+ percent => progress?.Report(Math.Clamp(percent / 100.0, 0, 1)), total);
+
+ var ok = await processor.ProcessAsynchronously(throwOnError: true, ffOptions).ConfigureAwait(false);
+ progress?.Report(1.0);
+
+ return ok && File.Exists(outPath)
+ ? ConvertResult.Ok(sourcePath, new[] { outPath })
+ : ConvertResult.Fail(sourcePath, "FFmpeg 변환에 실패했습니다.");
+ }
+ catch (OperationCanceledException) { throw; }
+ catch (Exception ex)
+ {
+ return ConvertResult.Fail(sourcePath, $"FFmpeg 변환 실패: {ex.Message}", ex);
+ }
+ }
+}
diff --git a/src/Everything2Everything.Core/Everything2Everything.Core.csproj b/src/Everything2Everything.Core/Everything2Everything.Core.csproj
index 5a1c2a5..d1a1e20 100644
--- a/src/Everything2Everything.Core/Everything2Everything.Core.csproj
+++ b/src/Everything2Everything.Core/Everything2Everything.Core.csproj
@@ -14,6 +14,7 @@
+
diff --git a/src/Everything2Everything.Core/Everything2EverythingBootstrap.cs b/src/Everything2Everything.Core/Everything2EverythingBootstrap.cs
index 8532b04..993ef3c 100644
--- a/src/Everything2Everything.Core/Everything2EverythingBootstrap.cs
+++ b/src/Everything2Everything.Core/Everything2EverythingBootstrap.cs
@@ -24,6 +24,7 @@ public static class Everything2EverythingBootstrap
new Converters.VectorProvider(),
new Converters.ImageOptimProvider(),
new Converters.LlmProvider(settings),
+ new Converters.FfmpegProvider(),
};
return new ConversionEngine(new ProviderRegistry(providers));
}
diff --git a/src/Everything2Everything.Tests/FfmpegProviderTests.cs b/src/Everything2Everything.Tests/FfmpegProviderTests.cs
new file mode 100644
index 0000000..e6114a3
--- /dev/null
+++ b/src/Everything2Everything.Tests/FfmpegProviderTests.cs
@@ -0,0 +1,40 @@
+using System.Linq;
+using System.Threading.Tasks;
+using Everything2Everything.Core;
+using Everything2Everything.Core.Converters;
+using Everything2Everything.Core.Providers;
+using Xunit;
+
+namespace Everything2Everything.Tests;
+
+public class FfmpegProviderTests
+{
+ [Fact]
+ public async Task WithoutFfmpeg_IsNotReady()
+ {
+ // 테스트 환경에 ffmpeg가 없다고 가정. 있으면 이 단언은 건너뜀.
+ if (ExternalToolDetector.TryFindFfmpeg(out _)) return;
+ var p = new FfmpegProvider();
+ var a = await p.CheckAvailabilityAsync();
+ Assert.False(a.IsReady);
+ Assert.NotNull(a.Reason);
+ }
+
+ [Fact]
+ public void Graph_HasVideoAndAudioEdges()
+ {
+ var graph = Everything2EverythingBootstrap.CreateDefault().Providers.Graph;
+ Assert.NotNull(graph.FindBestPath(".mp4", ".webm")); // 영상 트랜스코딩
+ Assert.NotNull(graph.FindBestPath(".wav", ".mp3")); // 오디오 변환
+ Assert.NotNull(graph.FindBestPath(".mp4", ".mp3")); // 영상→오디오 추출
+ }
+
+ [Fact]
+ public void Capability_DeclaresVideoAudioMatrix_NoSelfPairs()
+ {
+ var caps = new FfmpegProvider().Capability.SupportedConversions;
+ Assert.Contains(caps, c => c.InputExtension == ".mp4" && c.OutputExtension == ".mkv");
+ Assert.Contains(caps, c => c.InputExtension == ".flac" && c.OutputExtension == ".mp3");
+ Assert.DoesNotContain(caps, c => c.InputExtension == c.OutputExtension); // 자기쌍 없음
+ }
+}