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" }; private static readonly string[] ImageFromVideo = { ".png", ".jpg", ".jpeg", ".webp", ".bmp" }; 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)); // 영상 → 오디오 추출 pairs.AddRange(ProviderCapability.PairsFromMatrix(Video, ImageFromVideo, LossClass.Rasterize)); // 영상 → 대표 프레임 이미지 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 }; var inExt = ConversionPair.Normalize(Path.GetExtension(sourcePath)); var isImageOut = outExt is ".png" or ".jpg" or ".jpeg" or ".webp" or ".bmp"; try { progress?.Report(0.1); // 영상 → 이미지: 대표 프레임 1장 추출 (gif 경유 멀티홉을 피해 즉시 처리) if (isImageOut && Array.IndexOf(Video, inExt) >= 0) { var okImg = await FFMpegArguments .FromFileInput(sourcePath) .OutputToFile(outPath, overwrite: true, o => o.WithCustomArgument("-frames:v 1 -update 1")) .CancellableThrough(cancellationToken) .ProcessAsynchronously(throwOnError: true, ffOptions) .ConfigureAwait(false); progress?.Report(1.0); return okImg && File.Exists(outPath) ? ConvertResult.Ok(sourcePath, new[] { outPath }) : ConvertResult.Fail(sourcePath, "영상에서 프레임 추출에 실패했습니다."); } // 진행률 best-effort: ffprobe로 길이를 알면 시간 기반 보고 TimeSpan total = TimeSpan.Zero; try { total = (await FFProbe.AnalyseAsync(sourcePath, ffOptions, cancellationToken).ConfigureAwait(false)).Duration; } catch { /* 길이 미상이면 진행률 생략 */ } // GPU(NVENC) 가속은 H.264 컨테이너(mp4/mkv/mov)에만 적용 시도하고, 실패하면 CPU로 폴백 var tryGpu = options.VideoPreferGpu && (outExt is ".mp4" or ".mkv" or ".mov"); async Task RunAsync(bool gpu) { var processor = FFMpegArguments .FromFileInput(sourcePath) .OutputToFile(outPath, overwrite: true, o => { if (gpu) o.WithVideoCodec("h264_nvenc"); }) .CancellableThrough(cancellationToken); if (total > TimeSpan.Zero) processor = processor.NotifyOnProgress( percent => progress?.Report(Math.Clamp(percent / 100.0, 0, 1)), total); return await processor.ProcessAsynchronously(throwOnError: true, ffOptions).ConfigureAwait(false); } bool ok; try { ok = await RunAsync(tryGpu).ConfigureAwait(false); } catch when (tryGpu && !cancellationToken.IsCancellationRequested) { // NVENC 미지원(GPU 없음 등) → CPU 인코더로 폴백 progress?.Report(0.05); ok = await RunAsync(false).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); } } }